diff --git a/Admin/rtbPublishReferenceData.m b/Admin/rtbPublishReferenceData.m
new file mode 100644
index 0000000000000000000000000000000000000000..7b376d0eb731c219e5398a195769129225b6e50d
--- /dev/null
+++ b/Admin/rtbPublishReferenceData.m
@@ -0,0 +1,92 @@
+function artifacts = rtbPublishReferenceData(varargin)
+% Use RemoteDataToolbox to publish reference data to brainard-archiva.
+%
+% Archiva server "brainard-archiva" on AWS at http://52.32.77.154/
+% and repository called RenderToolbox.
+% see rdt-config-render-toolbox.json
+%
+% Reference data on Amazon S3 at
+%   s3://render-toolbox-reference/all-example-scenes/2016-10-26-21-24-21
+%
+% Use yas3fs to mount the bucket before running this script.  Then this
+% script can use the file system instead of the S3 API.
+%
+% Should run this from an AWS instance under the Brainard Lab AWS accout.
+% This will avoid data transfer to the local workstation, which will make
+% it go a lot faster and cheaper!
+%
+% For each example scene, make a zip archive.  Publish at a path like
+%   reference-renderings/rtbMakeDragon
+%
+% Use the "epic scene test" date as the version, like
+%   2016-10-26-21-24-21
+%
+
+parser = inputParser();
+parser.addParameter('rdtConfig', 'render-toolbox');
+parser.addParameter('referenceRoot', pwd(), @ischar);
+parser.addParameter('tempRoot', fullfile(tempdir(), 'rtbPublishReferenceData'), @ischar);
+parser.addParameter('referenceVersion', 'test', @ischar);
+parser.addParameter('remotePath', 'reference-data', @ischar);
+parser.addParameter('deployToolboxes', true, @islogical);
+parser.addParameter('dryRun', true, @islogical);
+parser.parse(varargin{:});
+rdtConfig = parser.Results.rdtConfig;
+referenceRoot = parser.Results.referenceRoot;
+tempRoot = parser.Results.tempRoot;
+referenceVersion = parser.Results.referenceVersion;
+remotePath = parser.Results.remotePath;
+deployToolboxes = parser.Results.deployToolboxes;
+dryRun = parser.Results.dryRun;
+
+if deployToolboxes
+    tbUse({'RenderToolbox4', 'RemoteDataToolbox'});
+end
+
+if 7 ~= exist(tempRoot, 'dir')
+    mkdir(tempRoot);
+end
+
+% iterate subfolders of referenceRoot for example names
+[exampleNames, nExamples] = subfolderNames(referenceRoot);
+artifactCell = cell(1, nExamples);
+for ee = 1:nExamples
+    exampleName = exampleNames{ee};
+    exampleDir = fullfile(referenceRoot, exampleName);
+    
+    % zip up the example
+    archiveTemp = fullfile(tempRoot, [exampleName '.zip']);
+    if ~dryRun
+        zip(archiveTemp, '.', exampleDir);
+    end
+    
+    % publish the zip
+    artifactCell{ee} = publishFile(rdtConfig, archiveTemp, remotePath,...
+        exampleName, referenceVersion, dryRun);
+end
+artifacts = [artifactCell{:}];
+
+function [names, nNames] = subfolderNames(parentPath)
+parentDir = dir(parentPath);
+parentDir = parentDir(3:end);
+names = {parentDir([parentDir.isdir]).name};
+nNames = numel(names);
+
+
+function artifact = publishFile(rdtConfig, fileName, remotePath, exampleName, versionName, dryRun)
+
+% describe the example
+artifactPath = fullfile(remotePath, exampleName);
+description = sprintf('version <%s> example <%s>', versionName, exampleName);
+disp(description);
+
+if dryRun
+    artifact = [];
+    return;
+end
+
+% go ahead and publish the dir
+artifact = rdtPublishArtifact(rdtConfig, fileName, artifactPath, ...
+    'artifactId', exampleName, ...
+    'version', versionName, ...
+    'rescan', true);
diff --git a/BatchRenderer/AssimpStrategy/BasicMappings/rtbResourcePath.m b/BatchRenderer/AssimpStrategy/BasicMappings/rtbResourcePath.m
deleted file mode 100644
index 7e9403cc5f4e39956f820a594b8625a4c8ca9115..0000000000000000000000000000000000000000
--- a/BatchRenderer/AssimpStrategy/BasicMappings/rtbResourcePath.m
+++ /dev/null
@@ -1,206 +0,0 @@
-function [outName, isLocated, info] = rtbResourcePath(inName, varargin)
-%% Build a reference to a resource file mentioned in a scene.
-%
-% The idea here is to resolve file references and clean up those
-% reference so that they point to existing local files.  This is
-% useful because "wild" scenes from the web often contain:
-%   - renferences to non-existent files
-%   - absolute file paths from another computer
-%   - truncated file names
-%   - file names with the wrong CASE
-%   - file names with non-ASCII characters
-%   - etc.
-% These are all a pain in the neck, but many of these can be resolved
-% automatically.
-%
-% [outName, info] = rtbResourcePath(inName) attempts to do fuzzy
-% matching between the given fileName and the files found in pwd().
-% When a match is found, the fileName will be updated.
-%
-% rtbResourcePath( ... 'strictMatching', strictMatching) whether to perform
-% exact file name matching (true) or fuzzy matching, which is more
-% permissive and less accurate (false).  The default is false, do
-% permissive fuzzy matching.
-%
-% rtbResourcePath( ... 'resourceFolder', resourceFolder) specifies
-% a folder to search for local existing files.  The default is pwd().
-%
-% rtbResourcePath( ... 'writeFullPaths', writeFullPaths) choose
-% whether to update the given fileName with a full absolute paths (true),
-% or to write just file names without any leading path (false).  The
-% default is true, write full, absolute paths.
-%
-% rtbResourcePath( ... 'relativePath', relativePath) specify
-% a prefix to add to updated file name, when writeFullPaths is false.
-% This is useful in case the located resource file is in a subfolder
-% relative to a scene file.  The default is '', don't prepend any relative
-% path.
-%
-% rtbResourcePath( ... 'toReplace', toReplace)
-% specifies a string containing characters to be replaced in resource file
-% names.  When found, these characters will be replaced with "_"
-% underscores.  This is useful to prevent Assimp from transcoding non-ASCII
-% characters.  For example, Assimp transcodes "-" as "%2d", which is good
-% for UTF-8, but breaks some downstream programs like PBRT and Mitsuba.
-% The default is '-:', replace hyphens and colons with underscores.
-%
-% rtbResourcePath( ... 'copyOnReplace', copyOnReplace) choose
-% whether to copy files when their names contain replaced characters (true)
-% or not (false).  This is useful so that renamed resources will point to
-% existing files.  The default is true, make new copies or resource files
-% as their names are replaced.
-%
-% rtbResourcePath( ... 'useMatlabPath', useMatlabPath) specifies whether
-% to search the Matlab path for resource files, after searching the given
-% resourceFolder.  The default is true, do search the Matlab path.
-%
-% Returns the given fileName, with modifications.  Also returns a logical
-% flag, true when the resource was located.  Also returns a struct
-% of information about what happened.
-%
-% [outName, isLocated, info] = rtbResourcePath(inName, varargin)
-%
-% Copyright (c) 2016 mexximp Teame
-
-parser = inputParser();
-parser.addRequired('inName', @ischar);
-parser.addParameter('strictMatching', false, @islogical);
-parser.addParameter('resourceFolder', pwd(), @ischar);
-parser.addParameter('writeFullPaths', true, @islogical);
-parser.addParameter('relativePath', '', @ischar);
-parser.addParameter('toReplace', '-:', @ischar);
-parser.addParameter('copyOnReplace', true, @islogical);
-parser.addParameter('useMatlabPath', true, @islogical);
-parser.parse(inName, varargin{:});
-inName = parser.Results.inName;
-strictMatching = parser.Results.strictMatching;
-resourceFolder = parser.Results.resourceFolder;
-writeFullPaths = parser.Results.writeFullPaths;
-relativePath = parser.Results.relativePath;
-toReplace = parser.Results.toReplace;
-copyOnReplace = parser.Results.copyOnReplace;
-useMatlabPath = parser.Results.useMatlabPath;
-
-if strictMatching
-    matchFunction = @strictMatch;
-else
-    matchFunction = @fuzzyMatch;
-end
-
-isLocated = false;
-
-
-%% Collect files in the resourceFolder.
-resourceDir = dir(resourceFolder);
-isDir = [resourceDir.isdir];
-resources = {resourceDir(~isDir).name};
-
-
-%% Try to find a the file in the given resources folder or Matlab path.
-resourceMatch = matchResource(inName, resources, matchFunction);
-if isempty(resourceMatch) && useMatlabPath && 2 == exist(inName, 'file')
-    % copy into resources folder and proceed like it was always there
-    fullPathName = which(inName);
-    resourceCopy = fullfile(resourceFolder, inName);
-    copyfile(fullPathName, resourceCopy, 'f');
-    resourceMatch = inName;
-end
-
-
-%% Did we find it?
-if isempty(resourceMatch)
-    % report an unmatched file
-    info.verbatimName = inName;
-    info.writtenName = inName;
-    info.isMatched = false;
-    info.matchName = '';
-    info.matchFullPath = '';
-    outName = inName;
-    return;
-end
-
-
-%% Replace unwanted characters in the file name.
-newName = replaceCharacters(resourceMatch, toReplace);
-if copyOnReplace && ~isempty(newName)
-    source = fullfile(resourceFolder, resourceMatch);
-    destination = fullfile(resourceFolder, newName);
-    copyfile(source, destination, 'f');
-    resourceMatch = newName;
-end
-
-
-%% Choose a new file name.
-isLocated = true;
-resourceFullPath = fullfile(resourceFolder, resourceMatch);
-resourceRelativePath = fullfile(relativePath, resourceMatch);
-if writeFullPaths
-    outName = resourceFullPath;
-else
-    outName = resourceRelativePath;
-end
-
-
-%% Report a successful update.
-info.verbatimName = inName;
-info.writtenName = outName;
-info.isMatched = true;
-info.matchName = resourceMatch;
-info.matchFullPath = resourceFullPath;
-
-
-%% Find unwanted characters and replace with underscores.
-function newName = replaceCharacters(name, toReplace)
-newName = '';
-needsReplacement = false(1, numel(name));
-for ii = 1:numel(toReplace)
-    needsReplacement = needsReplacement | toReplace(ii) == name;
-end
-if any(needsReplacement)
-    newName = name;
-    newName(needsReplacement) = '_';
-end
-
-
-%% Iterate resources and try to match against a given file.
-function resourceMatch = matchResource(fileName, resources, matchFunction)
-resourceMatch = '';
-nResources = numel(resources);
-for ii = 1:nResources
-    resource = resources{ii};
-    if feval(matchFunction, fileName, resource)
-        resourceMatch = resource;
-        return;
-    end
-end
-
-
-%% Fuzzy matching for file names: is b probably a good substitute for a?
-%   case insensitive
-%   4851-nor.jpg matches 4851-normal.jpg
-%   C:\foo\bar\baz.jpg matches baz.jpg
-function isMatch = fuzzyMatch(a, b)
-a = lower(a);
-b = lower(b);
-
-[~, aBase, aExt] = fileparts(a);
-[~, bBase, bExt] = fileparts(b);
-
-% one extension is a substring of the other,
-%   and the base names match
-isMatch = (~isempty(strfind(aExt, bExt)) || ~isempty(strfind(bExt, aExt))) ...
-    && (~isempty(strfind(aBase, bBase)) || ~isempty(strfind(bBase, aBase)));
-
-
-%% Strict matching for file names: b is the same as a, within reason.
-%   case insensitive
-%   4851-nor.jpg does not match 4851-normal.jpg
-%   C:\foo\bar\baz.jpg matches baz.jpg
-function isMatch = strictMatch(a, b)
-a = lower(a);
-b = lower(b);
-
-[~, aBase, aExt] = fileparts(a);
-[~, bBase, bExt] = fileparts(b);
-
-isMatch = strcmp(aBase, bBase) && strcmp(aExt, bExt);
diff --git a/BatchRenderer/AssimpStrategy/RtbAssimpStrategy.m b/BatchRenderer/AssimpStrategy/RtbAssimpStrategy.m
index 1e7e1b9495a533cb4206cdb86c22b7e725861c91..9400b8fa9bc1ac0ebf07f88d164c2debb4eb748a 100644
--- a/BatchRenderer/AssimpStrategy/RtbAssimpStrategy.m
+++ b/BatchRenderer/AssimpStrategy/RtbAssimpStrategy.m
@@ -137,7 +137,13 @@ classdef RtbAssimpStrategy < RtbBatchRenderStrategy
                 scene = mexximpLoad(sceneFile);
             else
                 % import anew
-                scene = mexximpCleanImport(sceneFile, obj.importArgs{:});
+                resourceFolder = rtbWorkingFolder( ...
+                    'folderName', 'resources', ...
+                    'rendererSpecific', false, ...
+                    'hints', obj.hints);
+                scene = mexximpCleanImport(sceneFile, ...
+                    'workingFolder', resourceFolder, ...
+                    obj.importArgs{:});
             end
         end
         
@@ -169,41 +175,45 @@ classdef RtbAssimpStrategy < RtbBatchRenderStrategy
         
         function [scene, mappings] = resolveResources(obj, scene, mappings)
             % locate files and fix up names
-            resourceFolder = rtbWorkingFolder( ...
-                'folderName', 'resources', ...
+            workingFolder = rtbWorkingFolder( ...
                 'rendererSpecific', false, ...
                 'hints', obj.hints);
-            mappings = mexximpVisitStructFields(mappings, @rtbResourcePath, ...
+            mappings = mexximpVisitStructFields(mappings, @mexximpResolveResource, ...
                 'filterFunction', @RtbAssimpStrategy.mightBeFile, ...
                 'ignoreFields', {'rootNode', 'embeddedTextures', 'meshes', 'lights', 'cameras'}, ...
                 'visitArgs', { ...
                 'strictMatching', true, ...
-                'resourceFolder', resourceFolder, ...
-                'writeFullPaths', false, ...
-                'relativePath', 'resources', ...
-                'toReplace', ':-', ...
-                'copyOnReplace', true});
-            scene = mexximpVisitStructFields(scene, @rtbResourcePath, ...
+                'useMatlabPath', true, ...
+                'sourceFolder', workingFolder, ...
+                'outputFolder', workingFolder, ...
+                'outputPrefix', 'resources', ...
+                'outputReplaceCharacters', ':-', ...
+                'outputReplaceWith', '_'});
+            scene = mexximpVisitStructFields(scene, @mexximpResolveResource, ...
                 'filterFunction', @RtbAssimpStrategy.mightBeFile, ...
                 'ignoreFields', {'rootNode', 'embeddedTextures', 'meshes', 'lights', 'cameras'}, ...
                 'visitArgs', { ...
                 'strictMatching', true, ...
-                'resourceFolder', resourceFolder, ...
-                'writeFullPaths', false, ...
-                'relativePath', 'resources', ...
-                'toReplace', ':-', ...
-                'copyOnReplace', true});
+                'sourceFolder', workingFolder, ...
+                'outputFolder', workingFolder, ...
+                'outputPrefix', 'resources', ...
+                'outputReplaceCharacters', ':-', ...
+                'outputReplaceWith', '_'});
             
             mappings = mexximpVisitStructFields(mappings, @mexximpRecodeImage, ...
                 'filterFunction', @RtbAssimpStrategy.mightBeFile, ...
                 'ignoreFields', {'rootNode', 'embeddedTextures', 'meshes', 'lights', 'cameras'}, ...
                 'visitArgs', { ...
+                'sceneFolder', workingFolder, ...
+                'skipExisting', true, ...
                 'toReplace', {'gif'}, ...
                 'targetFormat', 'png'});
             scene = mexximpVisitStructFields(scene, @mexximpRecodeImage, ...
                 'filterFunction', @RtbAssimpStrategy.mightBeFile, ...
                 'ignoreFields', {'rootNode', 'embeddedTextures', 'meshes', 'lights', 'cameras'}, ...
                 'visitArgs', { ...
+                'sceneFolder', workingFolder, ...
+                'skipExisting', true, ...
                 'toReplace', {'gif'}, ...
                 'targetFormat', 'png'});
         end
diff --git a/BatchRenderer/AssimpStrategy/MitsubaConverter/RtbAssimpMitsubaConverter.m b/BatchRenderer/Renderers/Mitsuba/MitsubaConverter/RtbAssimpMitsubaConverter.m
similarity index 100%
rename from BatchRenderer/AssimpStrategy/MitsubaConverter/RtbAssimpMitsubaConverter.m
rename to BatchRenderer/Renderers/Mitsuba/MitsubaConverter/RtbAssimpMitsubaConverter.m
diff --git a/BatchRenderer/AssimpStrategy/MitsubaConverter/rtbApplyMMitsubaGenericMappings.m b/BatchRenderer/Renderers/Mitsuba/MitsubaConverter/rtbApplyMMitsubaGenericMappings.m
similarity index 100%
rename from BatchRenderer/AssimpStrategy/MitsubaConverter/rtbApplyMMitsubaGenericMappings.m
rename to BatchRenderer/Renderers/Mitsuba/MitsubaConverter/rtbApplyMMitsubaGenericMappings.m
diff --git a/BatchRenderer/AssimpStrategy/MitsubaConverter/rtbApplyMMitsubaMappingOperation.m b/BatchRenderer/Renderers/Mitsuba/MitsubaConverter/rtbApplyMMitsubaMappingOperation.m
similarity index 100%
rename from BatchRenderer/AssimpStrategy/MitsubaConverter/rtbApplyMMitsubaMappingOperation.m
rename to BatchRenderer/Renderers/Mitsuba/MitsubaConverter/rtbApplyMMitsubaMappingOperation.m
diff --git a/BatchRenderer/AssimpStrategy/MitsubaConverter/rtbApplyMMitsubaMappings.m b/BatchRenderer/Renderers/Mitsuba/MitsubaConverter/rtbApplyMMitsubaMappings.m
similarity index 100%
rename from BatchRenderer/AssimpStrategy/MitsubaConverter/rtbApplyMMitsubaMappings.m
rename to BatchRenderer/Renderers/Mitsuba/MitsubaConverter/rtbApplyMMitsubaMappings.m
diff --git a/BatchRenderer/AssimpStrategy/MitsubaConverter/rtbMMitsubaBlessAsAreaLight.m b/BatchRenderer/Renderers/Mitsuba/MitsubaConverter/rtbMMitsubaBlessAsAreaLight.m
similarity index 100%
rename from BatchRenderer/AssimpStrategy/MitsubaConverter/rtbMMitsubaBlessAsAreaLight.m
rename to BatchRenderer/Renderers/Mitsuba/MitsubaConverter/rtbMMitsubaBlessAsAreaLight.m
diff --git a/BatchRenderer/AssimpStrategy/MitsubaConverter/rtbMMitsubaBlessAsBumpMap.m b/BatchRenderer/Renderers/Mitsuba/MitsubaConverter/rtbMMitsubaBlessAsBumpMap.m
similarity index 100%
rename from BatchRenderer/AssimpStrategy/MitsubaConverter/rtbMMitsubaBlessAsBumpMap.m
rename to BatchRenderer/Renderers/Mitsuba/MitsubaConverter/rtbMMitsubaBlessAsBumpMap.m
diff --git a/BatchRenderer/Renderers/RtbMitsubaRenderer.m b/BatchRenderer/Renderers/Mitsuba/RtbMitsubaRenderer.m
similarity index 100%
rename from BatchRenderer/Renderers/RtbMitsubaRenderer.m
rename to BatchRenderer/Renderers/Mitsuba/RtbMitsubaRenderer.m
diff --git a/BatchRenderer/Renderers/Mitsuba/rtbRenderMitsubaFactoids.m b/BatchRenderer/Renderers/Mitsuba/rtbRenderMitsubaFactoids.m
new file mode 100644
index 0000000000000000000000000000000000000000..e67959e8879ba242fb8be0d6f8a22aa2c9676bf4
--- /dev/null
+++ b/BatchRenderer/Renderers/Mitsuba/rtbRenderMitsubaFactoids.m
@@ -0,0 +1,93 @@
+function [factoids, exrOutput] = rtbRenderMitsubaFactoids(sceneFile, varargin)
+% Obtain ground truth "factoids" about a Mitsuba scene.
+%
+% [factoids, exrOutput] = rtbRenderMitsubaFactoids(sceneFile) invokes
+% Mitsuba to obtain ground truth scene "factoids".  Returns a struct array
+% of ground truth images, with one field per ground truth factoid.
+%
+% The given sceneFile must specify a "multichannel" integrator with one or
+% more nested "field" integrators.  You can create such scenes with
+% rtbWriteMitsubaFactoidScene().
+%
+% rtbRenderMitsubaFactoids( ... 'mitsuba', mitsuba) specify a struct of
+% info about the installed Mitsuba renderer.  For some factoids, this must
+% be a version of Mistuba compiled for RGB rendering, not spectral
+% rendering.  The default is taken from getpref('Mitsuba').
+%
+% rtbRenderMitsubaFactoids(... 'hints', hints)
+% Specify a struct of RenderToolbox options.  If hints is omitted, values
+% are taken from rtbDefaultHints().
+%
+%%% RenderToolbox4 Copyright (c) 2012-2017 The RenderToolbox Team.
+%%% About Us://github.com/RenderToolbox/RenderToolbox4/wiki/About-Us
+%%% RenderToolbox4 is released under the MIT License.  See LICENSE file.
+
+parser = inputParser();
+parser.addRequired('sceneFile', @ischar);
+parser.addParameter('mitsuba', [], @(m) isempty(m) || isstruct(m));
+parser.addParameter('hints', rtbDefaultHints(), @isstruct);
+parser.parse(sceneFile, varargin{:});
+sceneFile = parser.Results.sceneFile;
+mitsuba = parser.Results.mitsuba;
+hints = rtbDefaultHints(parser.Results.hints);
+
+if isempty(mitsuba)
+    % modify default mitsuba config to look for rgb
+    mitsuba = getpref('Mitsuba');
+    mitsuba.dockerImage = 'ninjaben/mitsuba-rgb';
+    mitsuba.kubernetesPodSelector = 'app=mitsuba-spectral';
+    if ismac()
+        mitsuba.app = '/Applications/Mitsuba-RGB.app';
+    else
+        mitsuba.executable = 'mitusba-rgb';
+    end
+end
+
+% look carefully for the input file
+workingFolder = rtbWorkingFolder('hints', hints);
+fileInfo = rtbResolveFilePath(sceneFile, workingFolder);
+sceneFile = fileInfo.absolutePath;
+
+
+%% Render the factoid scene.renderer = RtbMitsubaRenderer(hints);
+renderer = RtbMitsubaRenderer(hints);
+renderer.mitsuba = mitsuba;
+
+[~, ~, exrOutput] = renderer.renderToExr(sceneFile);
+[sliceInfo, data] = ReadMultichannelEXR(exrOutput);
+
+
+%% Group data slices by factoid name.
+factoids = struct();
+factoidSize = size(data);
+for ii = 1:numel(sliceInfo)
+    % factoid channels have names like albedo.R, albedo.G, albedo.B
+    split = find(sliceInfo(ii).name == '.');
+    factoidName = sliceInfo(ii).name(1:split-1);
+    channelName = sliceInfo(ii).name(split+1:end);
+    
+    % initialize factoid output with data array and channel names
+    if ~isfield(factoids, factoidName)
+        factoids.(factoidName).data = ...
+            zeros(factoidSize(1), factoidSize(2), 0);
+        factoids.(factoidName).channels = {};
+    end
+    
+    % sort channels, which may arrive out of order
+    switch channelName
+        case 'R'
+            dataIndex = 1;
+        case 'G'
+            dataIndex = 2;
+        case 'B'
+            dataIndex = 3;
+        otherwise
+            dataIndex = numel(factoids.(factoidName).channels) + 1;
+    end
+    
+    % insert data and channel name into output for this factoid
+    slice = data(:,:,ii);
+    factoids.(factoidName).data(:, :, dataIndex) = slice;
+    factoids.(factoidName).channels{dataIndex} = channelName;
+end
+
diff --git a/BatchRenderer/Renderers/Mitsuba/rtbWriteMitsubaFactoidScene.m b/BatchRenderer/Renderers/Mitsuba/rtbWriteMitsubaFactoidScene.m
new file mode 100644
index 0000000000000000000000000000000000000000..e6c909086366f31b40456190f11feaadd5b5202e
--- /dev/null
+++ b/BatchRenderer/Renderers/Mitsuba/rtbWriteMitsubaFactoidScene.m
@@ -0,0 +1,135 @@
+function factoidFile = rtbWriteMitsubaFactoidScene(originalFile, varargin)
+% Convert the given scene to get factoids instead of ray tracing.
+%
+% factoidFile = rtbWriteMitsubaFactoidScene(originalFile) copies and
+% modifies the given originalFile so that it will produce Mitsuba ground
+% truth "factoids" instead of ray tracing data.  Returns a new Mitsuba
+% scene file based on the given originalFile.
+%
+% The returned sceneFile woill specify a "multichannel" integrator with one
+% or more nested "field" integrators.  You can pass this file to
+% rtbRenderMitsubaFactoids() to obtain the factoid data.
+%
+% rtbWriteMitsubaFactoidScene( ... 'factoids', factoids) specify a cell
+% array of ground truth factoid names to be obtained.  The default includes
+% all available factoids:
+%   - 'position' - absolute position of the object under each pixel
+%   - 'relPosition' - camera-relative position of the object under each pixel
+%   - 'distance' - distance to camera of the object under each pixel
+%   - 'geoNormal' - surface normal at the surface under each pixel
+%   - 'shNormal' - surface normal at the surface under each pixel, interpolated for shading
+%   - 'uv' - texture mapping UV coordinates at the surface under each pixel
+%   - 'albedo' - diffuse reflectance of the object under each pixel
+%   - 'shapeIndex' - integer identifier for the object under each pixel
+%   - 'primIndex' - integer identifier for the triangle or other primitive under each pixel
+%
+% rtbWriteMitsubaFactoidScene( ... 'factoidFormat', factoidFormat) specify
+% a mitsuba pixel format to use for formatting the output, like 'rgb' or
+% 'spectrum'.  The default is 'rgb'.
+%
+% rtbWriteMitsubaFactoidScene( ... 'singleSampling', singleSampling)
+% whether or not to do a simplified rendering with one sample per pixel and
+% a narrow "box" reconstruction filder.  This is useful for labeling
+% factoids like shapeIndex, where it doesn't make sense to average across
+% multiple ray samples.  The default is true, do a simplified rendering.
+%
+%%% RenderToolbox4 Copyright (c) 2012-2017 The RenderToolbox Team.
+%%% About Us://github.com/RenderToolbox/RenderToolbox4/wiki/About-Us
+%%% RenderToolbox4 is released under the MIT License.  See LICENSE file.
+
+parser = inputParser();
+parser.addRequired('originalFile', @ischar);
+parser.addParameter('factoidFile', '', @ischar);
+parser.addParameter('factoids', ...
+    {'position', 'relPosition', 'distance', 'geoNormal', 'shNormal', ...
+    'uv', 'albedo', 'shapeIndex', 'primIndex'}, ...
+    @iscellstr);
+parser.addParameter('factoidFormat', 'rgb', @ischar);
+parser.addParameter('singleSampling', true, @islogical);
+parser.addParameter('hints', rtbDefaultHints(), @isstruct);
+parser.parse(originalFile, varargin{:});
+originalFile = parser.Results.originalFile;
+factoidFile = parser.Results.factoidFile;
+factoids = parser.Results.factoids;
+factoidFormat = parser.Results.factoidFormat;
+singleSampling = parser.Results.singleSampling;
+hints = rtbDefaultHints(parser.Results.hints);
+
+% look carefully for the input file
+workingFolder = rtbWorkingFolder('hints', hints);
+fileInfo = rtbResolveFilePath(originalFile, workingFolder);
+originalFile = fileInfo.absolutePath;
+
+% default output like the input
+if isempty(factoidFile)
+    [factoidPath, factoidBase] = fileparts(originalFile);
+    factoidFile = fullfile(factoidPath, [factoidBase '-factoids.xml']);
+end
+
+
+%% Read the in the scene xml document.
+sceneDocument = xml2struct(originalFile);
+
+
+%% Replace the integrator for multiple "fields".
+
+% "multichannel" integrator to hold several "fields"
+integrator.Attributes.id = 'integrator';
+integrator.Attributes.type = 'multichannel';
+sceneDocument.scene.integrator = integrator;
+
+% nested "field" for each factoid
+nFactoids = numel(factoids);
+fieldIntegrators = cell(1, nFactoids);
+for ff = 1:nFactoids
+    factoidName = factoids{ff};
+    
+    fieldIntegrator = struct();
+    fieldIntegrator.Attributes.name = factoidName;
+    fieldIntegrator.Attributes.type = 'field';
+    fieldIntegrator.string.Attributes.name = 'field';
+    fieldIntegrator.string.Attributes.value = factoidName;
+    
+    fieldIntegrators{ff} = fieldIntegrator;
+end
+sceneDocument.scene.integrator.integrator = fieldIntegrators;
+
+
+%% Replace the film for exr and factoid formats.
+sceneDocument.scene.sensor.film.Attributes.type = 'hdrfilm';
+filmStrings = cell(1, 4);
+filmStrings{1}.Attributes.name = 'componentFormat';
+filmStrings{1}.Attributes.value = 'float16';
+filmStrings{2}.Attributes.name = 'fileFormat';
+filmStrings{2}.Attributes.value = 'openexr';
+
+[formatCell{1:nFactoids}] = deal(factoidFormat);
+formatList = sprintf('%s, ', formatCell{:});
+filmStrings{3}.Attributes.name = 'pixelFormat';
+filmStrings{3}.Attributes.value = formatList(1:end-2);
+
+nameList = sprintf('%s, ', factoids{:});
+filmStrings{4}.Attributes.name = 'channelNames';
+filmStrings{4}.Attributes.value = nameList(1:end-2);
+
+sceneDocument.scene.sensor.film.string = filmStrings;
+
+
+%% Replace the filter and sampler for simplified rendering?
+if singleSampling
+    sampler.Attributes.id = 'sampler';
+    sampler.Attributes.type = 'ldsampler';
+    sampler.integer.Attributes.name = 'sampleCount';
+    sampler.integer.Attributes.value = 1;
+    sceneDocument.scene.sensor.sampler = sampler;
+    
+    rfilter.Attributes.id = 'rfilter';
+    rfilter.Attributes.type = 'box';
+    rfilter.float.Attributes.name = 'radius';
+    rfilter.float.Attributes.value= 0.5;
+    sceneDocument.scene.sensor.film.rfilter = rfilter;
+end
+
+
+%% Write back the scene document.
+struct2xml(sceneDocument, factoidFile);
diff --git a/BatchRenderer/AssimpStrategy/PBRTConverter/RtbAssimpPBRTConverter.m b/BatchRenderer/Renderers/PBRT/PBRTConverter/RtbAssimpPBRTConverter.m
similarity index 85%
rename from BatchRenderer/AssimpStrategy/PBRTConverter/RtbAssimpPBRTConverter.m
rename to BatchRenderer/Renderers/PBRT/PBRTConverter/RtbAssimpPBRTConverter.m
index baed1bc3e924c6b71f6503690e061b4741defec2..cf6c479e3f903dcab27e3fc727b1cac86dcd63b0 100644
--- a/BatchRenderer/AssimpStrategy/PBRTConverter/RtbAssimpPBRTConverter.m
+++ b/BatchRenderer/Renderers/PBRT/PBRTConverter/RtbAssimpPBRTConverter.m
@@ -15,6 +15,18 @@ classdef RtbAssimpPBRTConverter < RtbConverter
         % material parameter to receive specular reflectance data
         specularParameter;
         
+        % material parameter to receive glossy reflectance data
+        glossyParameter
+        
+        % material parameter to receive index of refraction
+        iorParameter
+        
+        % material parameter to receive roughness of the material
+        roughnessParameter
+        
+        % material parameter to receive opacity of the material
+        opacityParameter
+        
         % where to write output files, like scene or geometry
         outputFolder;
         
@@ -27,8 +39,14 @@ classdef RtbAssimpPBRTConverter < RtbConverter
     
     methods (Static)
         function material = defaultMaterial()
-            material = MPbrtElement.makeNamedMaterial('', 'matte');
+            material = MPbrtElement.makeNamedMaterial('', 'uber');
             material.setParameter('Kd', 'spectrum', '300:1 800:1');
+            material.setParameter('Ks', 'spectrum', '300:0 800:0');
+            material.setParameter('Kr', 'spectrum', '300:0 800:0');
+            material.setParameter('roughness','float',0.1);
+            material.setParameter('index','float',1.5);
+            material.setParameter('opacity', 'spectrum', '300:1 800:1');
+            
         end
     end
     
@@ -38,7 +56,11 @@ classdef RtbAssimpPBRTConverter < RtbConverter
             obj.hints = rtbDefaultHints(hints);
             obj.material = RtbAssimpPBRTConverter.defaultMaterial();
             obj.diffuseParameter = 'Kd';
-            obj.specularParameter = '';
+            obj.specularParameter = 'Ks';
+            obj.glossyParameter = 'Kr';
+            obj.iorParameter = 'index';
+            obj.roughnessParameter = 'roughness';
+            obj.opacityParameter = 'opacity';
             obj.outputFolder = rtbWorkingFolder('hints', obj.hints);
             obj.meshSubfolder = 'scenes/PBRT/pbrt-geometry';
             obj.rewriteMeshData = true;
@@ -118,6 +140,10 @@ classdef RtbAssimpPBRTConverter < RtbConverter
                 'materialDefault', obj.material, ...
                 'materialDiffuseParameter', obj.diffuseParameter, ...
                 'materialSpecularParameter', obj.specularParameter, ...
+                'materialGlossyParameter', obj.glossyParameter, ...
+                'materialIorParameter', obj.iorParameter, ...
+                'materialOpacityParameter', obj.opacityParameter, ...
+                'materialRoughnessParamter', obj.roughnessParameter, ...
                 'workingFolder', obj.outputFolder, ...
                 'meshSubfolder', obj.meshSubfolder, ...
                 'rewriteMeshData', obj.rewriteMeshData);
diff --git a/BatchRenderer/AssimpStrategy/PBRTConverter/rtbApplyMPbrtGenericMappings.m b/BatchRenderer/Renderers/PBRT/PBRTConverter/rtbApplyMPbrtGenericMappings.m
similarity index 100%
rename from BatchRenderer/AssimpStrategy/PBRTConverter/rtbApplyMPbrtGenericMappings.m
rename to BatchRenderer/Renderers/PBRT/PBRTConverter/rtbApplyMPbrtGenericMappings.m
diff --git a/BatchRenderer/AssimpStrategy/PBRTConverter/rtbApplyMPbrtMappingOperation.m b/BatchRenderer/Renderers/PBRT/PBRTConverter/rtbApplyMPbrtMappingOperation.m
similarity index 100%
rename from BatchRenderer/AssimpStrategy/PBRTConverter/rtbApplyMPbrtMappingOperation.m
rename to BatchRenderer/Renderers/PBRT/PBRTConverter/rtbApplyMPbrtMappingOperation.m
diff --git a/BatchRenderer/AssimpStrategy/PBRTConverter/rtbApplyMPbrtMappings.m b/BatchRenderer/Renderers/PBRT/PBRTConverter/rtbApplyMPbrtMappings.m
similarity index 100%
rename from BatchRenderer/AssimpStrategy/PBRTConverter/rtbApplyMPbrtMappings.m
rename to BatchRenderer/Renderers/PBRT/PBRTConverter/rtbApplyMPbrtMappings.m
diff --git a/BatchRenderer/AssimpStrategy/PBRTConverter/rtbMPbrtBlessAsAreaLight.m b/BatchRenderer/Renderers/PBRT/PBRTConverter/rtbMPbrtBlessAsAreaLight.m
similarity index 100%
rename from BatchRenderer/AssimpStrategy/PBRTConverter/rtbMPbrtBlessAsAreaLight.m
rename to BatchRenderer/Renderers/PBRT/PBRTConverter/rtbMPbrtBlessAsAreaLight.m
diff --git a/BatchRenderer/AssimpStrategy/PBRTConverter/rtbMPbrtBlessAsBumpMap.m b/BatchRenderer/Renderers/PBRT/PBRTConverter/rtbMPbrtBlessAsBumpMap.m
similarity index 100%
rename from BatchRenderer/AssimpStrategy/PBRTConverter/rtbMPbrtBlessAsBumpMap.m
rename to BatchRenderer/Renderers/PBRT/PBRTConverter/rtbMPbrtBlessAsBumpMap.m
diff --git a/BatchRenderer/Renderers/RtbPBRTRenderer.m b/BatchRenderer/Renderers/PBRT/RtbPBRTRenderer.m
similarity index 100%
rename from BatchRenderer/Renderers/RtbPBRTRenderer.m
rename to BatchRenderer/Renderers/PBRT/RtbPBRTRenderer.m
diff --git a/BatchRenderer/Renderers/RtbSampleRendererRenderer.m b/BatchRenderer/Renderers/SampleRenderer/RtbSampleRendererRenderer.m
similarity index 100%
rename from BatchRenderer/Renderers/RtbSampleRendererRenderer.m
rename to BatchRenderer/Renderers/SampleRenderer/RtbSampleRendererRenderer.m
diff --git a/BatchRenderer/AssimpStrategy/SampleRendererConverter/RtbAssimpSampleRendererConverter.m b/BatchRenderer/Renderers/SampleRenderer/SampleRendererConverter/RtbAssimpSampleRendererConverter.m
similarity index 100%
rename from BatchRenderer/AssimpStrategy/SampleRendererConverter/RtbAssimpSampleRendererConverter.m
rename to BatchRenderer/Renderers/SampleRenderer/SampleRendererConverter/RtbAssimpSampleRendererConverter.m
diff --git a/ExampleScenes/Flythrough/rtbMakeFlythrough.m b/ExampleScenes/Flythrough/rtbMakeFlythrough.m
index 3ffec08a25780cb70f0f8a592d097da5dfaa9ec4..ae410b6402ccfbd6fd44c7bfe3286a6a98194f02 100644
--- a/ExampleScenes/Flythrough/rtbMakeFlythrough.m
+++ b/ExampleScenes/Flythrough/rtbMakeFlythrough.m
@@ -10,15 +10,26 @@ nightClubFile = fullfile(rtbRoot(), 'ExampleScenes', 'Flythrough', 'NightClub',
 millenniumFalconFile = fullfile(rtbRoot(), 'ExampleScenes', 'Flythrough', 'MilleniumFalcon', 'millenium-falcon.obj');
 
 
+%% Choose batch renderer options.
+hints.imageWidth = 320;
+hints.imageHeight = 240;
+hints.fov = deg2rad(60);
+hints.recipeName = 'rtbMakeFlythrough';
+
+hints.renderer = 'Mitsuba';
+hints.batchRenderStrategy = RtbAssimpStrategy(hints);
+hints.batchRenderStrategy.remodelPerConditionAfterFunction = @rtbFlythroughMexximpRemodeler;
+hints.batchRenderStrategy.converter.remodelAfterMappingsFunction = @rtbFlythroughMitsubaRemodeler;
+
+resourceFolder = rtbWorkingFolder('hints', hints, ...
+    'folderName', 'resources');
+
+
 %% Build a combined scene with lights and camera.
-nightClub = mexximpCleanImport(nightClubFile);
-falcon = mexximpCleanImport(millenniumFalconFile);
-falcon = mexximpVisitStructFields(falcon, @rtbResourcePath, ...
-    'filterFunction', @RtbAssimpStrategy.mightBeFile, ...
-    'visitArgs', { ...
-    'resourceFolder', fileparts(millenniumFalconFile), ...
-    'toReplace', '', ...
-    'writeFullPaths', false});
+nightClub = mexximpCleanImport(nightClubFile, ...
+    'workingFolder', resourceFolder);
+falcon = mexximpCleanImport(millenniumFalconFile, ...
+    'workingFolder', resourceFolder);
 
 falconSize = 50;
 insertTransform = mexximpScale(falconSize * [1 1 1]);
@@ -33,18 +44,6 @@ scene = mexximpCentralizeCamera(scene, 'viewExterior', false);
 %mexximpScenePreview(scene);
 
 
-%% Choose batch renderer options.
-hints.imageWidth = 320;
-hints.imageHeight = 240;
-hints.fov = deg2rad(60);
-hints.recipeName = 'rtbMakeFlythrough';
-
-hints.renderer = 'Mitsuba';
-hints.batchRenderStrategy = RtbAssimpStrategy(hints);
-hints.batchRenderStrategy.remodelPerConditionAfterFunction = @rtbFlythroughMexximpRemodeler;
-hints.batchRenderStrategy.converter.remodelAfterMappingsFunction = @rtbFlythroughMitsubaRemodeler;
-
-
 %% Choose some waypoints for the camera and falcon movement.
 falconPositionWaypoints = [ ...
     143 54 -44; ...
@@ -105,6 +104,7 @@ rtbWriteConditionsFile(conditionsFile, names, values);
 
 
 %% Make Scene files.
+hints.whichConditions = 3;
 nativeSceneFiles = rtbMakeSceneFiles(scene, ...
     'conditionsFile', conditionsFile, ...
     'hints', hints);
diff --git a/ExampleScenes/Interior/rtbMakeInteriorMitsubaFactoids.m b/ExampleScenes/Interior/rtbMakeInteriorMitsubaFactoids.m
new file mode 100644
index 0000000000000000000000000000000000000000..fa3929be3af774a3c3449f35dba3509d96eda038
--- /dev/null
+++ b/ExampleScenes/Interior/rtbMakeInteriorMitsubaFactoids.m
@@ -0,0 +1,80 @@
+%%% RenderToolbox4 Copyright (c) 2012-2017 The RenderToolbox Team.
+%%% About Us://github.com/RenderToolbox/RenderToolbox4/wiki/About-Us
+%%% RenderToolbox4 is released under the MIT License.  See LICENSE file.
+%
+%% Render the Interior scene with various lighting.
+
+%% Choose example files, make sure they're on the Matlab path.
+parentSceneFile = 'interio.dae';
+conditionsFile = 'InteriorConditions.txt';
+mappingsFile = 'InteriorMappings.json';
+
+%% Choose batch renderer options.
+hints.whichConditions = 1;
+hints.fov = 49.13434 * pi() / 180;
+hints.recipeName = 'rtbMakeInteriorFactoids';
+
+%% Write some spectra to use.
+resources = rtbWorkingFolder('folderName', 'resources', 'hints', hints);
+cieInfo = load('B_cieday');
+
+% make orange-yellow for a few lights
+temp = 4000;
+scale = 3;
+spd = scale * GenerateCIEDay(temp, cieInfo.B_cieday);
+wls = SToWls(cieInfo.S_cieday);
+rtbWriteSpectrumFile(wls, spd, fullfile(resources, 'YellowLight.spd'));
+
+% make strong yellow for the hanging spot light
+temp = 5000;
+scale = 30;
+spd = scale * GenerateCIEDay(temp, cieInfo.B_cieday);
+wls = SToWls(cieInfo.S_cieday);
+rtbWriteSpectrumFile(wls, spd, fullfile(resources, 'HangingLight.spd'));
+
+% make daylight for the windows behind the camera
+[wavelengths, magnitudes] = rtbReadSpectrum('D65.spd');
+scale = 1;
+magnitudes = scale * magnitudes;
+rtbWriteSpectrumFile(wavelengths, magnitudes, fullfile(resources, 'WindowLight.spd'));
+
+%% Obtain factoids with Mitsuba.
+
+% start by generating a regular scene file.
+hints.renderer = 'Mitsuba';
+nativeSceneFiles = rtbMakeSceneFiles(parentSceneFile, ...
+    'mappingsFile', mappingsFile, ...
+    'conditionsFile', conditionsFile, ...
+    'hints', hints);
+
+% convert regular scene file for factoid rendering
+factoidSceneFile = rtbWriteMitsubaFactoidScene(nativeSceneFiles{1}, ...
+    'hints', hints);
+
+% invoke Mitsuba to get the factoids
+factoids = rtbRenderMitsubaFactoids(factoidSceneFile, ...
+    'hints', hints);
+
+%% Plot the factoids.
+factoidNames = fieldnames(factoids);
+nFactoids = numel(factoidNames);
+rows = 3;
+columns = ceil(nFactoids / rows);
+for ff = 1:nFactoids
+    factoidName = factoidNames{ff};
+    factoid = factoids.(factoidName);
+    
+    subplot(rows, columns, ff);
+    
+    switch factoidName
+        case {'primIndex', 'shapeIndex'}
+            % index/nominal data with colormap
+            imshow(factoid.data(:,:,1), prism());
+        otherwise
+            % continuous data as stretched rgb
+            stretchedRgb = factoid.data ./ max(factoid.data(:));
+            imshow(stretchedRgb);
+    end
+    
+    title(factoidName);
+end
diff --git a/ExampleScenes/WildScene/rtbMakeWildScene.m b/ExampleScenes/WildScene/rtbMakeWildScene.m
index 3fdfbe9d435c350b515653efeda603c994455140..b76979da48518ebf1c96717df60a23f4302aac28 100644
--- a/ExampleScenes/WildScene/rtbMakeWildScene.m
+++ b/ExampleScenes/WildScene/rtbMakeWildScene.m
@@ -26,12 +26,24 @@
 
 clear;
 clc;
+
 pathHere = fileparts(which('rtbMakeWildScene'));
 
+%% Choose batch processing options.
+hints.imageWidth = 640;
+hints.imageHeight = 480;
+hints.recipeName = 'rtbMakeWildScene';
+hints.renderer = 'Mitsuba';
+hints.batchRenderStrategy = RtbAssimpStrategy(hints);
+
+resourceFolder = rtbWorkingFolder('hints', hints, ...
+    'folderName', 'resources');
+
 
 %% Choose a scene format -- how does the 3DS Max scene look?
 wildScene = fullfile(pathHere, 'millenium-falcon.3DS');
-scene = mexximpCleanImport(wildScene);
+scene = mexximpCleanImport(wildScene, ...
+    'workingFolder', resourceFolder);
 
 % look at the vertices in a scatter plot
 mexximpSceneScatter(scene);
@@ -39,7 +51,8 @@ mexximpSceneScatter(scene);
 
 %% Choose a better scene format -- the Wavefront Object.
 wildScene = fullfile(pathHere, 'millenium-falcon.obj');
-scene = mexximpCleanImport(wildScene);
+scene = mexximpCleanImport(wildScene, ...
+    'workingFolder', resourceFolder);
 
 % look at the vertices in a scatter plot
 mexximpSceneScatter(scene);
@@ -68,38 +81,10 @@ scene = mexximpAddLanterns(scene);
 disp(displayNicelyFormattedStruct(scene, 'scene', '', 50));
 
 
-%% Fix broken texture paths.
-
-% obj version of the scene comes with an mtl material file
-% this file contains bad file paths for a Windows user named "glenn"
-% for example:
-disp(['Original path: ' scene.materials(2).properties(10).data]);
-
-% we can find and fix paths like this
-% by recursively fisiting fields of the scene struct
-% and doing some fuzzy matching on names
-scene = mexximpVisitStructFields(scene, @rtbResourcePath, ...
-    'filterFunction', @RtbAssimpStrategy.mightBeFile, ...
-    'visitArgs', {'resourceFolder', pathHere, 'toReplace', ''});
-
-% now we should have the file locally
-disp(['Local path: ' scene.materials(2).properties(10).data]);
-
-
-%% Choose batch processing options.
-hints.imageWidth = 640;
-hints.imageHeight = 480;
-hints.fov = scene.cameras(1).horizontalFov;
-hints.recipeName = 'rtbMakeWildScene';
-hints.renderer = 'Mitsuba';
-hints.batchRenderStrategy = RtbAssimpStrategy(hints);
-
-
 %% Render with Mitsuba.
 
-% fix broken texture paths
-
 % make a scene file and render it
+hints.fov = scene.cameras(1).horizontalFov;
 nativeSceneFiles = rtbMakeSceneFiles(scene, 'hints', hints);
 radianceDataFiles = rtbBatchRender(nativeSceneFiles, 'hints', hints);
 
@@ -117,11 +102,9 @@ rtbShowXYZAndSRGB([], sRgb, montageName);
 
 
 %% Choose a nicer viewing axis and render again.
-scene = mexximpCleanImport(wildScene, 'flipUVs', true);
-scene = mexximpVisitStructFields(scene, @rtbResourcePath, ...
-    'filterFunction', @RtbAssimpStrategy.mightBeFile, ...
-    'visitArgs', {'resourceFolder', pathHere, 'toReplace', ''});
-
+scene = mexximpCleanImport(wildScene, ...
+    'workingFolder', resourceFolder, ...
+    'flipUVs', true);
 viewAxis = [-1 1 1];
 scene = mexximpCentralizeCamera(scene, 'viewAxis', viewAxis ./ norm(viewAxis));
 scene = mexximpAddLanterns(scene);
diff --git a/README.md b/README.md
index 0ef28221dc0f1240bfc6e4164bc0a400db64427b..dadbf967de5e3f5594f62a5b1a1b3684d9d96b2d 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,5 @@
+[![Build Status](http://50.112.42.141/buildStatus/icon?job=RenderToolbox4)](http://50.112.42.141/job/RenderToolbox4/)
+
 RenderToolbox4
 ==============
 RenderToolbox4 is a Matlab toolbox for working with 3D scenes and physically-based renderers.
diff --git a/Test/Automated/Fixture/Comparison/RecipesA/rtbMakeCoordinatesTest/renderings/Mitsuba/scene-001.mat b/Test/Automated/Fixture/Comparison/RecipesA/rtbMakeCoordinatesTest/renderings/Mitsuba/scene-001.mat
new file mode 100644
index 0000000000000000000000000000000000000000..91b97c1fb5bd7b3f3f3430d01789d7853c2c57e1
Binary files /dev/null and b/Test/Automated/Fixture/Comparison/RecipesA/rtbMakeCoordinatesTest/renderings/Mitsuba/scene-001.mat differ
diff --git a/Test/Automated/Fixture/Comparison/RecipesA/rtbMakeCoordinatesTest/renderings/PBRT/scene-001.mat b/Test/Automated/Fixture/Comparison/RecipesA/rtbMakeCoordinatesTest/renderings/PBRT/scene-001.mat
new file mode 100644
index 0000000000000000000000000000000000000000..87847b68e8bb9a9c672bf3f862a1f794973df60a
Binary files /dev/null and b/Test/Automated/Fixture/Comparison/RecipesA/rtbMakeCoordinatesTest/renderings/PBRT/scene-001.mat differ
diff --git a/Test/Automated/Fixture/Comparison/RecipesA/rtbMakeMaterialSphereBumps/renderings/Mitsuba/materialSphereMatte.mat b/Test/Automated/Fixture/Comparison/RecipesA/rtbMakeMaterialSphereBumps/renderings/Mitsuba/materialSphereMatte.mat
new file mode 100644
index 0000000000000000000000000000000000000000..147757056e2f6d9761765ad2b533fe88fa5fa65a
Binary files /dev/null and b/Test/Automated/Fixture/Comparison/RecipesA/rtbMakeMaterialSphereBumps/renderings/Mitsuba/materialSphereMatte.mat differ
diff --git a/Test/Automated/Fixture/Comparison/RecipesA/rtbMakeMaterialSphereBumps/renderings/Mitsuba/materialSphereMetal.mat b/Test/Automated/Fixture/Comparison/RecipesA/rtbMakeMaterialSphereBumps/renderings/Mitsuba/materialSphereMetal.mat
new file mode 100644
index 0000000000000000000000000000000000000000..ec74afcd8290b20e9f36097cba384fe64cb5c3d8
Binary files /dev/null and b/Test/Automated/Fixture/Comparison/RecipesA/rtbMakeMaterialSphereBumps/renderings/Mitsuba/materialSphereMetal.mat differ
diff --git a/Test/Automated/Fixture/Comparison/RecipesA/rtbMakeMaterialSphereBumps/renderings/Mitsuba/materialSphereWard.mat b/Test/Automated/Fixture/Comparison/RecipesA/rtbMakeMaterialSphereBumps/renderings/Mitsuba/materialSphereWard.mat
new file mode 100644
index 0000000000000000000000000000000000000000..ab258e33129912f2dad8db80ce287b2d46accd4b
Binary files /dev/null and b/Test/Automated/Fixture/Comparison/RecipesA/rtbMakeMaterialSphereBumps/renderings/Mitsuba/materialSphereWard.mat differ
diff --git a/Test/Automated/Fixture/Comparison/RecipesA/rtbMakeMaterialSphereBumps/renderings/PBRT/materialSphereMatte.mat b/Test/Automated/Fixture/Comparison/RecipesA/rtbMakeMaterialSphereBumps/renderings/PBRT/materialSphereMatte.mat
new file mode 100644
index 0000000000000000000000000000000000000000..ae0d5c3171187ac6cf9f51b15d0831f13912d524
Binary files /dev/null and b/Test/Automated/Fixture/Comparison/RecipesA/rtbMakeMaterialSphereBumps/renderings/PBRT/materialSphereMatte.mat differ
diff --git a/Test/Automated/Fixture/Comparison/RecipesA/rtbMakeMaterialSphereBumps/renderings/PBRT/materialSphereMetal.mat b/Test/Automated/Fixture/Comparison/RecipesA/rtbMakeMaterialSphereBumps/renderings/PBRT/materialSphereMetal.mat
new file mode 100644
index 0000000000000000000000000000000000000000..581b93e5b347f15f1ba7d0cdecaaea7ec0cc5a3d
Binary files /dev/null and b/Test/Automated/Fixture/Comparison/RecipesA/rtbMakeMaterialSphereBumps/renderings/PBRT/materialSphereMetal.mat differ
diff --git a/Test/Automated/Fixture/Comparison/RecipesA/rtbMakeMaterialSphereBumps/renderings/PBRT/materialSphereWard.mat b/Test/Automated/Fixture/Comparison/RecipesA/rtbMakeMaterialSphereBumps/renderings/PBRT/materialSphereWard.mat
new file mode 100644
index 0000000000000000000000000000000000000000..531b93f5db0a6607ae6846f8c1ee2beee5dc0aff
Binary files /dev/null and b/Test/Automated/Fixture/Comparison/RecipesA/rtbMakeMaterialSphereBumps/renderings/PBRT/materialSphereWard.mat differ
diff --git a/Test/Automated/Fixture/Comparison/RecipesA/rtbMakeMaterialSphereRemodeled/renderings/Mitsuba/materialSphereMatte.mat b/Test/Automated/Fixture/Comparison/RecipesA/rtbMakeMaterialSphereRemodeled/renderings/Mitsuba/materialSphereMatte.mat
new file mode 100644
index 0000000000000000000000000000000000000000..9581989dddc44c36f608e625b8f8d5526b23cebe
Binary files /dev/null and b/Test/Automated/Fixture/Comparison/RecipesA/rtbMakeMaterialSphereRemodeled/renderings/Mitsuba/materialSphereMatte.mat differ
diff --git a/Test/Automated/Fixture/Comparison/RecipesA/rtbMakeMaterialSphereRemodeled/renderings/Mitsuba/materialSphereMetal.mat b/Test/Automated/Fixture/Comparison/RecipesA/rtbMakeMaterialSphereRemodeled/renderings/Mitsuba/materialSphereMetal.mat
new file mode 100644
index 0000000000000000000000000000000000000000..68288e28e102fc195ba0b794eae61485c586d794
Binary files /dev/null and b/Test/Automated/Fixture/Comparison/RecipesA/rtbMakeMaterialSphereRemodeled/renderings/Mitsuba/materialSphereMetal.mat differ
diff --git a/Test/Automated/Fixture/Comparison/RecipesA/rtbMakeMaterialSphereRemodeled/renderings/Mitsuba/materialSphereWard.mat b/Test/Automated/Fixture/Comparison/RecipesA/rtbMakeMaterialSphereRemodeled/renderings/Mitsuba/materialSphereWard.mat
new file mode 100644
index 0000000000000000000000000000000000000000..de843d723570e18cb2e736e1044026f0fd93a5a0
Binary files /dev/null and b/Test/Automated/Fixture/Comparison/RecipesA/rtbMakeMaterialSphereRemodeled/renderings/Mitsuba/materialSphereWard.mat differ
diff --git a/Test/Automated/Fixture/Comparison/RecipesA/rtbMakeMaterialSphereRemodeled/renderings/PBRT/materialSphereMatte.mat b/Test/Automated/Fixture/Comparison/RecipesA/rtbMakeMaterialSphereRemodeled/renderings/PBRT/materialSphereMatte.mat
new file mode 100644
index 0000000000000000000000000000000000000000..db91b26ce2d86e0a604c259679c02dc950380ee6
Binary files /dev/null and b/Test/Automated/Fixture/Comparison/RecipesA/rtbMakeMaterialSphereRemodeled/renderings/PBRT/materialSphereMatte.mat differ
diff --git a/Test/Automated/Fixture/Comparison/RecipesA/rtbMakeMaterialSphereRemodeled/renderings/PBRT/materialSphereMetal.mat b/Test/Automated/Fixture/Comparison/RecipesA/rtbMakeMaterialSphereRemodeled/renderings/PBRT/materialSphereMetal.mat
new file mode 100644
index 0000000000000000000000000000000000000000..141ea0c82a44a3f8213304a0a8d7aa24bac68324
Binary files /dev/null and b/Test/Automated/Fixture/Comparison/RecipesA/rtbMakeMaterialSphereRemodeled/renderings/PBRT/materialSphereMetal.mat differ
diff --git a/Test/Automated/Fixture/Comparison/RecipesA/rtbMakeMaterialSphereRemodeled/renderings/PBRT/materialSphereWard.mat b/Test/Automated/Fixture/Comparison/RecipesA/rtbMakeMaterialSphereRemodeled/renderings/PBRT/materialSphereWard.mat
new file mode 100644
index 0000000000000000000000000000000000000000..23ffa17b5bac30b8dcf5c4c2d18e1d66ebce4afb
Binary files /dev/null and b/Test/Automated/Fixture/Comparison/RecipesA/rtbMakeMaterialSphereRemodeled/renderings/PBRT/materialSphereWard.mat differ
diff --git a/Test/Automated/Fixture/Comparison/RecipesB/rtbMakeCoordinatesTest/renderings/Mitsuba/scene-001.mat b/Test/Automated/Fixture/Comparison/RecipesB/rtbMakeCoordinatesTest/renderings/Mitsuba/scene-001.mat
new file mode 100644
index 0000000000000000000000000000000000000000..ed07771aae9652a555818dfe65c126dd304bffb9
Binary files /dev/null and b/Test/Automated/Fixture/Comparison/RecipesB/rtbMakeCoordinatesTest/renderings/Mitsuba/scene-001.mat differ
diff --git a/Test/Automated/Fixture/Comparison/RecipesB/rtbMakeCoordinatesTest/renderings/PBRT/scene-001.mat b/Test/Automated/Fixture/Comparison/RecipesB/rtbMakeCoordinatesTest/renderings/PBRT/scene-001.mat
new file mode 100644
index 0000000000000000000000000000000000000000..b1d7f2875e428ab210b346360b25c78e8f62c9de
Binary files /dev/null and b/Test/Automated/Fixture/Comparison/RecipesB/rtbMakeCoordinatesTest/renderings/PBRT/scene-001.mat differ
diff --git a/Test/Automated/Fixture/Comparison/RecipesB/rtbMakeMaterialSphereBumps/renderings/Mitsuba/materialSphereMatte.mat b/Test/Automated/Fixture/Comparison/RecipesB/rtbMakeMaterialSphereBumps/renderings/Mitsuba/materialSphereMatte.mat
new file mode 100644
index 0000000000000000000000000000000000000000..a5c6d16e3fb02ff1678e85120260cac738e1c849
Binary files /dev/null and b/Test/Automated/Fixture/Comparison/RecipesB/rtbMakeMaterialSphereBumps/renderings/Mitsuba/materialSphereMatte.mat differ
diff --git a/Test/Automated/Fixture/Comparison/RecipesB/rtbMakeMaterialSphereBumps/renderings/Mitsuba/materialSphereMetal.mat b/Test/Automated/Fixture/Comparison/RecipesB/rtbMakeMaterialSphereBumps/renderings/Mitsuba/materialSphereMetal.mat
new file mode 100644
index 0000000000000000000000000000000000000000..2c66232fd55136020ebebc16e966f274763d252d
Binary files /dev/null and b/Test/Automated/Fixture/Comparison/RecipesB/rtbMakeMaterialSphereBumps/renderings/Mitsuba/materialSphereMetal.mat differ
diff --git a/Test/Automated/Fixture/Comparison/RecipesB/rtbMakeMaterialSphereBumps/renderings/Mitsuba/materialSphereWard.mat b/Test/Automated/Fixture/Comparison/RecipesB/rtbMakeMaterialSphereBumps/renderings/Mitsuba/materialSphereWard.mat
new file mode 100644
index 0000000000000000000000000000000000000000..105f862a6f126f030fd43affa8dcc1bc11c33257
Binary files /dev/null and b/Test/Automated/Fixture/Comparison/RecipesB/rtbMakeMaterialSphereBumps/renderings/Mitsuba/materialSphereWard.mat differ
diff --git a/Test/Automated/Fixture/Comparison/RecipesB/rtbMakeMaterialSphereBumps/renderings/PBRT/materialSphereMatte.mat b/Test/Automated/Fixture/Comparison/RecipesB/rtbMakeMaterialSphereBumps/renderings/PBRT/materialSphereMatte.mat
new file mode 100644
index 0000000000000000000000000000000000000000..cc408994ce9c36cf910823a70ad931fbfe4f76d2
Binary files /dev/null and b/Test/Automated/Fixture/Comparison/RecipesB/rtbMakeMaterialSphereBumps/renderings/PBRT/materialSphereMatte.mat differ
diff --git a/Test/Automated/Fixture/Comparison/RecipesB/rtbMakeMaterialSphereBumps/renderings/PBRT/materialSphereMetal.mat b/Test/Automated/Fixture/Comparison/RecipesB/rtbMakeMaterialSphereBumps/renderings/PBRT/materialSphereMetal.mat
new file mode 100644
index 0000000000000000000000000000000000000000..38975361d6d9e56af6c9b38359ed3b0e90823f87
Binary files /dev/null and b/Test/Automated/Fixture/Comparison/RecipesB/rtbMakeMaterialSphereBumps/renderings/PBRT/materialSphereMetal.mat differ
diff --git a/Test/Automated/Fixture/Comparison/RecipesB/rtbMakeMaterialSphereBumps/renderings/PBRT/materialSphereWard.mat b/Test/Automated/Fixture/Comparison/RecipesB/rtbMakeMaterialSphereBumps/renderings/PBRT/materialSphereWard.mat
new file mode 100644
index 0000000000000000000000000000000000000000..9ab6a5cca1fca86ea126f3385918601ddc0aec25
Binary files /dev/null and b/Test/Automated/Fixture/Comparison/RecipesB/rtbMakeMaterialSphereBumps/renderings/PBRT/materialSphereWard.mat differ
diff --git a/Test/Automated/Fixture/Comparison/RecipesB/rtbMakeMaterialSphereRemodeled/renderings/Mitsuba/materialSphereMatte.mat b/Test/Automated/Fixture/Comparison/RecipesB/rtbMakeMaterialSphereRemodeled/renderings/Mitsuba/materialSphereMatte.mat
new file mode 100644
index 0000000000000000000000000000000000000000..acbf171682d78a79f713be6925cd5f03054c3c08
Binary files /dev/null and b/Test/Automated/Fixture/Comparison/RecipesB/rtbMakeMaterialSphereRemodeled/renderings/Mitsuba/materialSphereMatte.mat differ
diff --git a/Test/Automated/Fixture/Comparison/RecipesB/rtbMakeMaterialSphereRemodeled/renderings/Mitsuba/materialSphereMetal.mat b/Test/Automated/Fixture/Comparison/RecipesB/rtbMakeMaterialSphereRemodeled/renderings/Mitsuba/materialSphereMetal.mat
new file mode 100644
index 0000000000000000000000000000000000000000..840feacae1e34bf9beca74a98d148bad6b04cf91
Binary files /dev/null and b/Test/Automated/Fixture/Comparison/RecipesB/rtbMakeMaterialSphereRemodeled/renderings/Mitsuba/materialSphereMetal.mat differ
diff --git a/Test/Automated/Fixture/Comparison/RecipesB/rtbMakeMaterialSphereRemodeled/renderings/Mitsuba/materialSphereWard.mat b/Test/Automated/Fixture/Comparison/RecipesB/rtbMakeMaterialSphereRemodeled/renderings/Mitsuba/materialSphereWard.mat
new file mode 100644
index 0000000000000000000000000000000000000000..fe4e6ddd9e0b008a921695f01018e4eb70f90e27
Binary files /dev/null and b/Test/Automated/Fixture/Comparison/RecipesB/rtbMakeMaterialSphereRemodeled/renderings/Mitsuba/materialSphereWard.mat differ
diff --git a/Test/Automated/Fixture/Comparison/RecipesB/rtbMakeMaterialSphereRemodeled/renderings/PBRT/materialSphereMatte.mat b/Test/Automated/Fixture/Comparison/RecipesB/rtbMakeMaterialSphereRemodeled/renderings/PBRT/materialSphereMatte.mat
new file mode 100644
index 0000000000000000000000000000000000000000..066a22de2dc7bbf77a9559701a65ef4b03d8b956
Binary files /dev/null and b/Test/Automated/Fixture/Comparison/RecipesB/rtbMakeMaterialSphereRemodeled/renderings/PBRT/materialSphereMatte.mat differ
diff --git a/Test/Automated/Fixture/Comparison/RecipesB/rtbMakeMaterialSphereRemodeled/renderings/PBRT/materialSphereMetal.mat b/Test/Automated/Fixture/Comparison/RecipesB/rtbMakeMaterialSphereRemodeled/renderings/PBRT/materialSphereMetal.mat
new file mode 100644
index 0000000000000000000000000000000000000000..caca4383f23a79a1f9129a5b2dfd23034a1c8216
Binary files /dev/null and b/Test/Automated/Fixture/Comparison/RecipesB/rtbMakeMaterialSphereRemodeled/renderings/PBRT/materialSphereMetal.mat differ
diff --git a/Test/Automated/Fixture/Comparison/RecipesB/rtbMakeMaterialSphereRemodeled/renderings/PBRT/materialSphereWard.mat b/Test/Automated/Fixture/Comparison/RecipesB/rtbMakeMaterialSphereRemodeled/renderings/PBRT/materialSphereWard.mat
new file mode 100644
index 0000000000000000000000000000000000000000..ca18a50d8c7797877026ba535cd31e457364a78e
Binary files /dev/null and b/Test/Automated/Fixture/Comparison/RecipesB/rtbMakeMaterialSphereRemodeled/renderings/PBRT/materialSphereWard.mat differ
diff --git a/Test/Automated/RtbComparisonTests.m b/Test/Automated/RtbComparisonTests.m
new file mode 100644
index 0000000000000000000000000000000000000000..c15c8d7f5a4e99b0f97d0288eb30307d311ede12
--- /dev/null
+++ b/Test/Automated/RtbComparisonTests.m
@@ -0,0 +1,174 @@
+classdef RtbComparisonTests < matlab.unittest.TestCase
+    % Test functions that find and compare renderings.
+    
+    properties
+        folderA = fullfile(rtbRoot(), 'Test', 'Automated', 'Fixture', 'Comparison', 'RecipesA');
+        folderB = fullfile(rtbRoot(), 'Test', 'Automated', 'Fixture', 'Comparison', 'RecipesB');
+        scratchFolder = fullfile(tempdir(), 'RtbComparisonTests');
+    end
+    
+    methods (TestMethodSetup)
+        function cleanUpOutput(testCase)
+            if 7 == exist(testCase.scratchFolder, 'dir')
+                rmdir(testCase.scratchFolder, 's');
+            end
+        end
+    end
+    
+    
+    methods (Test)
+        
+        function testFindRenderingsSuccess(testCase)
+            expectedNames = {'rtbMakeCoordinatesTest', ...
+                'rtbMakeMaterialSphereBumps', ...
+                'rtbMakeMaterialSphereRemodeled'};
+            
+            renderingsA = rtbFindRenderings(testCase.folderA);
+            recipeNamesA = unique({renderingsA.recipeName});
+            testCase.assertEqual(recipeNamesA, expectedNames);
+            testCase.assertNumElements(renderingsA, 14);
+            
+            renderingsB = rtbFindRenderings(testCase.folderB);
+            recipeNamesB = unique({renderingsB.recipeName});
+            testCase.assertEqual(recipeNamesB, expectedNames);
+            testCase.assertNumElements(renderingsA, 14);
+        end
+        
+        function testFindRenderingsSubset(testCase)
+            renderings = rtbFindRenderings(testCase.folderA, ...
+                'filter', 'CoordinatesTest[^\.]+\.mat$');
+            testCase.assertNumElements(renderings, 2);
+            recipeNames = {renderings.recipeName};
+            testCase.assertEqual(strcmp(recipeNames, 'rtbMakeCoordinatesTest'), true(1,2));
+        end
+        
+        function testFindRenderingsNone(testCase)
+            renderings = rtbFindRenderings(testCase.folderA, ...
+                'filter', 'notagoodpattern');
+            testCase.assertEmpty(renderings);
+        end
+        
+        function fetchReferenceSuccess(testCase)
+            renderings = rtbFetchReferenceData('rtbMakeInterreflection', ...
+                'referenceRoot', testCase.scratchFolder);
+            testCase.assertNumElements(renderings, 6);
+            recipeNames = {renderings.recipeName};
+            testCase.assertEqual(strcmp(recipeNames, 'rtbMakeInterreflection'), true(1,6));
+        end
+        
+        function fetchReferenceNone(testCase)
+            renderings = rtbFetchReferenceData('nosuchrecipe', ...
+                'referenceRoot', testCase.scratchFolder);
+            testCase.assertNumElements(renderings, 0);
+        end
+        
+        function compareRenderingToSelf(testCase)
+            renderings = rtbFindRenderings(testCase.folderA);
+            comparison = rtbCompareRenderings(renderings(1), renderings(1));
+            testCase.assertEqual(comparison.corrcoef, 1, 'AbsTol', 1e-6);
+            testCase.assertEqual(comparison.relNormDiff.max, 0,'AbsTol', 1e-6);
+        end
+        
+        function compareRenderingToOther(testCase)
+            renderings = rtbFindRenderings(testCase.folderA);
+            comparison = rtbCompareRenderings(renderings(1), renderings(2));
+            testCase.assertLessThan(comparison.corrcoef, 1);
+            testCase.assertGreaterThan(comparison.relNormDiff.max, 0);
+        end
+        
+        function compareRenderingPlot(testCase)
+            renderings = rtbFindRenderings(testCase.folderA);
+            comparison = rtbCompareRenderings(renderings(1), renderings(1));
+            fig = rtbPlotRenderingComparison(comparison);
+            close(fig);
+        end
+        
+        function compareFolderToSelf(testCase)
+            comparisons = rtbCompareManyRecipes(testCase.folderA, testCase.folderA, ...
+                'fetchReferenceData', false);
+            testCase.assertNumElements(comparisons, 14);
+            testCase.assertEqual([comparisons.corrcoef], ones(1, 14), 'AbsTol', 1e-6);
+            relNormDiff = [comparisons.relNormDiff];
+            testCase.assertEqual([relNormDiff.max], zeros(1, 14) ,'AbsTol', 1e-6);
+        end
+        
+        function compareFolderToOther(testCase)
+            comparisons = rtbCompareManyRecipes(testCase.folderA, testCase.folderB, ...
+                'fetchReferenceData', false);
+            testCase.assertNumElements(comparisons, 14);
+            % real correlations can be unity, skip correlation test
+            relNormDiff = [comparisons.relNormDiff];
+            testCase.assertGreaterThan([relNormDiff.max], 0);
+        end
+        
+        function compareFolderToReference(testCase)
+            comparisons = rtbCompareManyRecipes(testCase.folderA, testCase.scratchFolder, ...
+                'fetchReferenceData', true);
+            testCase.assertNumElements(comparisons, 14);
+            % real correlations can be unity, skip correlation test
+            relNormDiff = [comparisons.relNormDiff];
+            testCase.assertGreaterThan([relNormDiff.max], 0);
+        end
+        
+        function compareFolderPlot(testCase)
+            comparisons = rtbCompareManyRecipes(testCase.folderA, testCase.folderA, ...
+                'fetchReferenceData', false);
+            fig = rtbPlotManyRecipeComparisons(comparisons);
+            close(fig);
+        end
+        
+        function epicComparisonPlots(testCase)
+            [comparisons, ~, figs] = rtbRunEpicComparison(testCase.folderA, testCase.folderA, ...
+                'plotSummary', true, ...
+                'plotImages', true);
+            testCase.assertNumElements(comparisons, 14);
+            testCase.assertNumElements(figs, 14 + 1);
+            close(figs);
+        end
+        
+        function epicComparisonNoPlots(testCase)
+            [comparisons, ~, figs] = rtbRunEpicComparison(testCase.folderA, testCase.folderA, ...
+                'plotSummary', false, ...
+                'plotImages', false);
+            testCase.assertNumElements(comparisons, 14);
+            testCase.assertEmpty(figs);
+        end
+        
+        function epicComparisonClosePlots(testCase)
+            [comparisons, ~, figs] = rtbRunEpicComparison(testCase.folderA, testCase.folderA, ...
+                'plotSummary', true, ...
+                'plotImages', true, ...
+                'closeSummary', true, ...
+                'closeImages', true);
+            testCase.assertNumElements(comparisons, 14);
+            testCase.assertEmpty(figs);
+        end
+        
+        function epicComparisonSavePlots(testCase)
+            [comparisons, ~, figs] = rtbRunEpicComparison(testCase.folderA, testCase.folderA, ...
+                'plotSummary', true, ...
+                'plotImages', true, ...
+                'closeSummary', true, ...
+                'closeImages', true, ...
+                'figureFolder', testCase.scratchFolder, ...
+                'summaryName', 'test-summary');
+            testCase.assertNumElements(comparisons, 14);
+            testCase.assertEmpty(figs);
+            
+            % summary should have been saved in scratch folder
+            summaryFig = fullfile(testCase.scratchFolder, 'test-summary.fig');
+            summaryPng = fullfile(testCase.scratchFolder, 'test-summary.png');
+            testCase.assertEqual(exist(summaryFig, 'file'), 2);
+            testCase.assertEqual(exist(summaryPng, 'file'), 2);
+            
+            % images should have been saved in the scratchFolder
+            for cc = 1:numel(comparisons)
+                identifier = comparisons(cc).renderingA.identifier;
+                imageFig = fullfile(testCase.scratchFolder, [identifier '.fig']);
+                imagePng = fullfile(testCase.scratchFolder, [identifier '.png']);
+                testCase.assertEqual(exist(imageFig, 'file'), 2);
+                testCase.assertEqual(exist(imagePng, 'file'), 2);
+            end
+        end
+    end
+end
diff --git a/Test/Automated/RtbExampleTests.m b/Test/Automated/RtbExampleTests.m
new file mode 100644
index 0000000000000000000000000000000000000000..bd07c83d137b88db3831335fef131c00c8e543ce
--- /dev/null
+++ b/Test/Automated/RtbExampleTests.m
@@ -0,0 +1,110 @@
+classdef RtbExampleTests < matlab.unittest.TestCase
+    % Execute some of the render toolbox examples.
+    
+    properties
+        outputRoot = fullfile(rtbWorkingFolder(), 'RtbExampleTests');
+        referenceRoot = fullfile(rtbWorkingFolder(), 'RtbReference');
+    end
+    
+    methods (TestMethodSetup)
+        function cleanUpOutput(testCase)
+            if 7 == exist(testCase.outputRoot, 'dir')
+                rmdir(testCase.outputRoot, 's');
+            end
+            
+            if 7 == exist(testCase.referenceRoot, 'dir')
+                rmdir(testCase.referenceRoot, 's');
+            end
+        end
+    end
+    
+    methods (Test)
+        
+        function testNotAnExample(testCase)
+            results = rtbRunEpicTest( ...
+                'outputRoot', testCase.outputRoot, ...
+                'makeFunctions', {'notAnExample.m'});
+            testCase.assertFalse(results.isSuccess);
+            testCase.assertNotEmpty(results.error);
+        end
+        
+        function testCoordinatesTest(testCase)
+            results = rtbRunEpicTest( ...
+                'outputRoot', testCase.outputRoot, ...
+                'makeFunctions', {'rtbMakeCoordinatesTest.m'});
+            testCase.assertTrue(results.isSuccess);
+            testCase.assertEmpty(results.error);
+            
+            % compare to reference rendering
+            comparisons = rtbCompareManyRecipes( ...
+                testCase.outputRoot, ...
+                testCase.referenceRoot, ...
+                'fetchReferenceData', true);
+            testCase.assertTrue(all([comparisons.isGoodComparison]));
+            
+            % Skip check for correlation and pixel differences.
+            % Because this scene doesn't specify its own materials, it is
+            % sensitive to the default material import behaviors of Assimp,
+            % mexximp, mMitsuba, and mPbrt.  So correlations and pixel
+            % differences may be large, even though the coordinate systems
+            % check out fine (which is the point of this example).
+        end
+        
+        function testDragon(testCase)
+            results = rtbRunEpicTest( ...
+                'outputRoot', testCase.outputRoot, ...
+                'makeFunctions', {'rtbMakeDragon.m'});
+            testCase.assertTrue(results.isSuccess);
+            testCase.assertEmpty(results.error);
+            
+            % compare to reference rendering
+            comparisons = rtbCompareManyRecipes( ...
+                testCase.outputRoot, ...
+                testCase.referenceRoot, ...
+                'fetchReferenceData', true);
+            testCase.assertTrue(all([comparisons.isGoodComparison]));
+            testCase.assertTrue(all([comparisons.corrcoef] > 0.75));
+            relNormDiffs = [comparisons.relNormDiff];
+            testCase.assertTrue(all([relNormDiffs.max] < 2.5));
+            testCase.assertTrue(all([relNormDiffs.mean] < 2.5));
+        end
+        
+        function testMaterialSphereBumps(testCase)
+            results = rtbRunEpicTest( ...
+                'outputRoot', testCase.outputRoot, ...
+                'makeFunctions', {'rtbMakeMaterialSphereBumps.m'});
+            testCase.assertTrue(results.isSuccess);
+            testCase.assertEmpty(results.error);
+            
+            % compare to reference rendering
+            comparisons = rtbCompareManyRecipes( ...
+                testCase.outputRoot, ...
+                testCase.referenceRoot, ...
+                'fetchReferenceData', true);
+            testCase.assertTrue(all([comparisons.isGoodComparison]));
+            testCase.assertTrue(all([comparisons.corrcoef] > 0.75));
+            relNormDiffs = [comparisons.relNormDiff];
+            testCase.assertTrue(all([relNormDiffs.max] < 2.5));
+            testCase.assertTrue(all([relNormDiffs.mean] < 2.5));
+        end
+        
+        function testMaterialSphereRemodeled(testCase)
+            results = rtbRunEpicTest( ...
+                'outputRoot', testCase.outputRoot, ...
+                'makeFunctions', {'rtbMakeMaterialSphereRemodeled.m'});
+            testCase.assertTrue(results.isSuccess);
+            testCase.assertEmpty(results.error);
+            
+            % compare to reference rendering
+            comparisons = rtbCompareManyRecipes( ...
+                testCase.outputRoot, ...
+                testCase.referenceRoot, ...
+                'fetchReferenceData', true);
+            testCase.assertTrue(all([comparisons.isGoodComparison]));
+            testCase.assertTrue(all([comparisons.corrcoef] > 0.75));
+            relNormDiffs = [comparisons.relNormDiff];
+            testCase.assertTrue(all([relNormDiffs.max] < 2.5));
+            testCase.assertTrue(all([relNormDiffs.mean] < 2.5));
+        end
+    end
+end
diff --git a/Test/Interactive/Comparison/rtbCompareManyRecipes.m b/Test/Interactive/Comparison/rtbCompareManyRecipes.m
new file mode 100644
index 0000000000000000000000000000000000000000..c620716a43bda6feec90449f4488064055c56aa9
--- /dev/null
+++ b/Test/Interactive/Comparison/rtbCompareManyRecipes.m
@@ -0,0 +1,121 @@
+function [comparisons, matchInfo] = rtbCompareManyRecipes(folderA, folderB, varargin)
+%% Compare paris of renderings across two folders.
+%
+% comparisons = rtbCompareManyRecipes(folderA, folderB) finds rendering
+% data files in the given folderA and folderB and attempts to match up
+% pairs of renderings that came from the same recipe and renderer.
+% For each pair, computes difference images and statistics.
+%
+% Returns a struct array of image comparisons, as returned from
+% rtbCompareRenderings().
+%
+% rtbCompareManyRecipes( ... 'fetchReferenceData', fetchReferenceData)
+% specify whether to use Remote Data Toolbox to fetch reference data for
+% comparison.  The default is true, fetch reference data when there is a
+% recipe in folderA that was not found in folderB, and cache the fetched
+% data in folderB.
+%
+%%% RenderToolbox4 Copyright (c) 2012-2017 The RenderToolbox Team.
+%%% About Us://github.com/RenderToolbox/RenderToolbox4/wiki/About-Us
+%%% RenderToolbox4 is released under the MIT License.  See LICENSE file.
+
+parser = inputParser();
+parser.KeepUnmatched = true;
+parser.addRequired('folderA', @ischar);
+parser.addRequired('folderB', @ischar);
+parser.addParameter('fetchReferenceData', true, @islogical);
+parser.parse(folderA, folderB, varargin{:});
+folderA = parser.Results.folderA;
+folderB = parser.Results.folderB;
+fetchReferenceData = parser.Results.fetchReferenceData;
+
+
+%% Identify renderings and recipes to compare.
+renderingsA = rtbFindRenderings(folderA, varargin{:});
+recipeNames = unique({renderingsA.recipeName});
+nRecipes = numel(recipeNames);
+
+renderingsB = rtbFindRenderings(folderB, varargin{:});
+
+%% Compare one recipe at a time, fetch data as necessary.
+comparisonsCell = cell(1, nRecipes);
+matchInfoCell = cell(1, nRecipes);
+for rr = 1:nRecipes
+    recipeName = recipeNames{rr};
+    fprintf('Comparing renderings for recipe <%s>.\n', recipeName);
+    
+    isRecipeA = strcmp({renderingsA.recipeName}, recipeName);
+    recipeRenderingsA = renderingsA(isRecipeA);
+    
+    % fetch missing recipe for B?
+    if isempty(renderingsB)
+        isRecipeB = false;
+    else
+        isRecipeB = strcmp({renderingsB.recipeName}, recipeName);
+    end
+    
+    if any(isRecipeB)
+        recipeRenderingsB = renderingsB(isRecipeB);
+    elseif fetchReferenceData
+        fprintf('  Fetching reference data to <%s>...\n', folderB);
+        recipeRenderingsB = rtbFetchReferenceData(recipeName, ...
+            'referenceRoot', folderB, ...
+            varargin{:});
+        if isempty(recipeRenderingsB)
+            fprintf('  ...could not fetch, skipping this recipe.\n');
+            continue;
+        else
+            fprintf('  ...OK.\n');
+        end
+    else
+        fprintf('  Skipping recipe not found in <%s>.\n', folderB);
+        continue;
+    end
+    
+    % match pairs of renderings for recipes A and B
+    info = matchRenderingPairs(recipeRenderingsA, recipeRenderingsB);
+    fprintf('  Found %d matched pairs of renderings.\n', info.nPairs);
+    
+    % run a comparison for each matched pair
+    pairsCell = cell(1, info.nPairs);
+    for pp = 1:info.nPairs
+        fprintf('    %s.\n', info.matchedA(pp).identifier);
+        pairsCell{pp} = rtbCompareRenderings(info.matchedA(pp), info.matchedB(pp), varargin{:});
+    end
+    comparisonsCell{rr} = [pairsCell{:}];
+    matchInfoCell{rr} = info;
+    
+    % report on unmatched renderings
+    if ~isempty(info.unmatchedA)
+        nUnmatched = info.unmatchedA;
+        fprintf('  %d renderings in A were not matched in B:\n', nUnmatched);
+        for uu = 1:nUnmatched
+            fprintf('    %s\n', info.info.unmatchedA(uu).identifier);
+        end
+    end
+    
+    if ~isempty(info.unmatchedB)
+        nUnmatched = info.unmatchedB;
+        fprintf('  %d renderings in B were not matched in A:\n', nUnmatched);
+        for uu = 1:nUnmatched
+            fprintf('    %s\n', info.info.unmatchedB(uu).identifier);
+        end
+    end
+end
+comparisons = [comparisonsCell{:}];
+matchInfo = [matchInfoCell{:}];
+
+
+%% For pairs of comparable renderings from two sets.
+function info = matchRenderingPairs(renderingsA, renderingsB)
+identifiersA = {renderingsA.identifier};
+identifiersB = {renderingsB.identifier};
+[~, indexA, indexB] = intersect(identifiersA, identifiersB, 'stable');
+[~, unmatchedIndexA] = setdiff(identifiersA, identifiersB);
+[~, unmatchedIndexB] = setdiff(identifiersB, identifiersA);
+
+info.nPairs = numel(indexA);
+info.matchedA = renderingsA(indexA);
+info.matchedB = renderingsB(indexB);
+info.unmatchedA = renderingsA(unmatchedIndexA);
+info.unmatchedB = renderingsB(unmatchedIndexB);
diff --git a/Test/Interactive/Comparison/rtbCompareRenderings.m b/Test/Interactive/Comparison/rtbCompareRenderings.m
new file mode 100644
index 0000000000000000000000000000000000000000..7ca36617f4c0c11634437b236dda6c28b3a8900c
--- /dev/null
+++ b/Test/Interactive/Comparison/rtbCompareRenderings.m
@@ -0,0 +1,194 @@
+function comparison = rtbCompareRenderings(renderingA, renderingB, varargin)
+%% Compare two renderings for difference images and statistics.
+%
+% comparison = rtbCompareRenderings(renderingA, renderingB) compares the
+% given renderingA against the given renderingB.  Each must be a rendering
+% record as returned from rtbRenderingRecord() or rtbFindRenderings().
+% Returns a comparison struct with many various fields including:
+%   - A -- the multispectral image from renderingA
+%   - B -- the multispectral image from renderingB
+%   - aMinusB -- the multispectral difference image A - B
+%   - bMinusA -- the multispectral difference image B - A
+%   - relNormDiff -- the min, mean, and max of per-pixel difference
+%   - corrcoef -- the overall correlation between A and B
+%   - error -- any error encountered during comparisons
+%
+% The relNormDiff is obtained by normalizing A and B each by its max value,
+% taking the absolute difference, and dividing the difference by the
+% normalized values of A.  Finally, values of relNormDiff that are less
+% than the given denominatorThreshold are set to nan.  This is to avoid
+% unfair comparisons when dividing by small values.
+%
+% rtbCompareRenderings( ... 'denominatorThreshold', denominatorThreshold)
+% specify the denominatorThreshold to use when computing the relNormDiff
+% image described above.  The default is 0.2.
+%
+%%% RenderToolbox4 Copyright (c) 2012-2017 The RenderToolbox Team.
+%%% About Us://github.com/RenderToolbox/RenderToolbox4/wiki/About-Us
+%%% RenderToolbox4 is released under the MIT License.  See LICENSE file.
+
+parser = inputParser();
+parser.KeepUnmatched = true;
+parser.addRequired('renderingA', @isstruct);
+parser.addRequired('renderingB', @isstruct);
+parser.addParameter('denominatorThreshold', 0.2, @isnumeric);
+parser.parse(renderingA, renderingB, varargin{:});
+renderingA = parser.Results.renderingA;
+renderingB = parser.Results.renderingB;
+denominatorThreshold = parser.Results.denominatorThreshold;
+
+
+%% Consistent struct fields for output.
+comparison = parser.Results;
+comparison.error = '';
+comparison.samplingA = [];
+comparison.samplingB = [];
+comparison.isGoodComparison = false;
+comparison.subpixelsA = [];
+comparison.subpixelsB = [];
+comparison.normA = [];
+comparison.normB = [];
+comparison.normDiff = [];
+comparison.absNormDiff = [];
+comparison.relNormDiff = [];
+comparison.corrcoef = [];
+comparison.A = [];
+comparison.B = [];
+comparison.aMinusB = [];
+comparison.bMinusA = [];
+
+
+%% Load multispectral images.
+dataA = load(renderingA.fileName);
+dataB = load(renderingB.fileName);
+
+% do we have images?
+hasImageA = isfield(dataA, 'multispectralImage');
+hasImageB = isfield(dataB, 'multispectralImage');
+if hasImageA
+    if hasImageB
+        % each has an image -- proceed
+    else
+        comparison.error = ...
+            'Data file A has a multispectralImage but B does not.';
+        disp(comparison.error);
+        return;
+    end
+else
+    if hasImageB
+        comparison.error = ...
+            'Data file B has a multispectralImage but A does not.';
+        disp(comparison.error);
+        return;
+    else
+        comparison.error = ...
+            'Neither data file A nor B has a multispectralImage.';
+        disp(comparison.error);
+        return;
+    end
+end
+multispectralA = dataA.multispectralImage;
+multispectralB = dataB.multispectralImage;
+
+
+%% Sanity check image dimensions.
+if ~isequal(size(multispectralA, 1), size(multispectralB, 1)) ...
+        || ~isequal(size(multispectralA, 2), size(multispectralB, 2))
+    
+    comparison.error = ...
+        sprintf('Image A[%s] is not the same size as image B[%s].', ...
+        num2str(size(multispectralA)), num2str(size(multispectralB)));
+    disp(comparison.error);
+    return;
+end
+
+
+%% Sanity check spectral sampling.
+hasSamplingA = isfield(dataA, 'S');
+hasSamplingB = isfield(dataB, 'S');
+if hasSamplingA
+    if hasSamplingB
+        % each has a sampling "S" -- proceed
+    else
+        comparison.error = ...
+            'Data file A has a spectral sampling "S" but B does not.';
+        disp(comparison.error);
+        return;
+    end
+else
+    if hasSamplingB
+        comparison.error = ...
+            'Data file B has a spectral sampling "S" but A does not.';
+        disp(comparison.error);
+        return;
+    else
+        comparison.error = ...
+            'Neither data file A nor B has spectral sampling "S".';
+        return;
+    end
+end
+comparison.samplingA = dataA.S;
+comparison.samplingB = dataB.S;
+
+% do spectral samplings agree?
+if ~isequal(dataA.S, dataB.S)
+    comparison.error = ...
+        sprintf('Spectral sampling A[%s] is not the same as B[%s].', ...
+        num2str(dataA.S), num2str(dataB.S));
+    disp(comparison.error);
+    % proceed with comparison, despite sampling mismatch
+end
+
+% match images based on sampling depths
+[multispectralA, multispectralB] = truncatePlanes( ...
+    multispectralA, multispectralB, dataA.S, dataB.S);
+
+
+%% Sanity Checks OK.
+comparison.isGoodComparison = true;
+
+
+%% Per-pixel difference stats.
+normA = multispectralA / max(multispectralA(:));
+normB = multispectralB / max(multispectralB(:));
+normDiff = normA - normB;
+absNormDiff = abs(normDiff);
+relNormDiff = absNormDiff ./ normA;
+relNormDiff(normA < denominatorThreshold) = nan;
+
+% summarize differnece stats
+comparison.subpixelsA = summarizeData(multispectralA);
+comparison.subpixelsB = summarizeData(multispectralB);
+comparison.normA = summarizeData(normA);
+comparison.normB = summarizeData(normB);
+comparison.normDiff = summarizeData(normDiff);
+comparison.absNormDiff = summarizeData(absNormDiff);
+comparison.relNormDiff = summarizeData(relNormDiff);
+
+
+%% Overall correlation.
+r = corrcoef(multispectralA(:), multispectralB(:));
+comparison.corrcoef = r(1, 2);
+
+
+%% Difference images.
+comparison.A = multispectralA;
+comparison.B = multispectralB;
+comparison.aMinusB = multispectralA - multispectralB;
+comparison.bMinusA = multispectralB - multispectralA;
+
+
+%% Truncate spectral planes if one image has more planes.
+function [truncA, truncB, truncSampling] = truncatePlanes(A, B, samplingA, samplingB)
+nPlanes = min(samplingA(3), samplingB(3));
+truncSampling = [samplingA(1:2) nPlanes];
+truncA = A(:,:,1:nPlanes);
+truncB = B(:,:,1:nPlanes);
+
+
+%% Summarize a distribuition of data with a struct of stats.
+function summary = summarizeData(data)
+finiteData = data(isfinite(data));
+summary.min = min(finiteData);
+summary.mean = mean(finiteData);
+summary.max = max(finiteData);
diff --git a/Test/Interactive/Comparison/rtbFetchReferenceData.m b/Test/Interactive/Comparison/rtbFetchReferenceData.m
new file mode 100644
index 0000000000000000000000000000000000000000..adca9695739b4a1568e18a11029764114c8b1172
--- /dev/null
+++ b/Test/Interactive/Comparison/rtbFetchReferenceData.m
@@ -0,0 +1,73 @@
+function [renderings, referenceRoot, artifact] = rtbFetchReferenceData(recipeName, varargin)
+% Fetch a reference rendering and make it available locally.
+%
+% renderings = rtbFetchReferenceData(recipeName) fetches a reference data
+% zip-file from the default brainard-archiva server.  The given recipeName
+% must be the name of an rtb example recipe, like 'rtbMakeDragon'.  Expands
+% the fetched zip file into the current directory.  Returns a struct array
+% of rendering data files that were found in the reference data.
+%
+% Also returns the path to the root folder where the zip file was expanded.
+% Also returns the RemoteDataToolbox artifact record for the fetched data.
+%
+% rtbFetchReferenceData( ... 'rdtConfig', rdtConfig) specify the Remote
+% Data Toolbox configuration to use.  The default is 'render-toolbox'.
+%
+% rtbFetchReferenceData( ... 'remotePath', remotePath) specify the Remote
+% Data Toolbox artifact path to use.  The default is 'reference-data'.
+%
+% rtbFetchReferenceData( ... 'referenceVersion', referenceVersion) specify
+% the Remote Data Toolbox artifact version to fetch.  The default is '+',
+% the latest available.
+%
+% rtbFetchReferenceData( ... 'referenceRoot', referenceRoot) specify the
+% root folder where to expand the fetched zip file.  The default is pwd().
+%
+% [renderings, referenceRoot, artifact] = rtbFetchReferenceData(recipeName, varargin)
+%
+%%% RenderToolbox4 Copyright (c) 2012-2017 The RenderToolbox Team.
+%%% About Us://github.com/RenderToolbox/RenderToolbox4/wiki/About-Us
+%%% RenderToolbox4 is released under the MIT License.  See LICENSE file.
+
+parser = inputParser();
+parser.KeepUnmatched = true;
+parser.addRequired('recipeName', @ischar);
+parser.addParameter('rdtConfig', 'render-toolbox');
+parser.addParameter('remotePath', 'reference-data', @ischar);
+parser.addParameter('referenceVersion', '+', @ischar);
+parser.addParameter('referenceRoot', pwd(), @ischar);
+parser.parse(recipeName, varargin{:});
+recipeName = parser.Results.recipeName;
+rdtConfig = parser.Results.rdtConfig;
+remotePath = parser.Results.remotePath;
+referenceVersion = parser.Results.referenceVersion;
+referenceRoot = parser.Results.referenceRoot;
+
+%% Get a whole recipe from the server.
+artifactPath = fullfile(remotePath, recipeName);
+try
+    [fileName, artifact] = rdtReadArtifact(rdtConfig, artifactPath, recipeName, ...
+        'version', referenceVersion, ...
+        'type', 'zip');
+catch err
+    renderings = [];
+    artifact = [];
+    return;
+end
+
+if isempty(fileName)
+    renderings = [];
+    artifact = [];
+    return;
+end
+
+
+%% Explode renderings it into the destination folder.
+destination = fullfile(referenceRoot, recipeName);
+if 7 ~= exist(destination, 'dir')
+    mkdir(destination);
+end
+unzip(fileName, destination);
+
+% scan for rendering records
+renderings = rtbFindRenderings(destination);
diff --git a/Test/Interactive/Comparison/rtbFindRenderings.m b/Test/Interactive/Comparison/rtbFindRenderings.m
new file mode 100644
index 0000000000000000000000000000000000000000..274c18179ec0dea76d42a3512a2f63af0842d140
--- /dev/null
+++ b/Test/Interactive/Comparison/rtbFindRenderings.m
@@ -0,0 +1,94 @@
+function renderings = rtbFindRenderings(rootFolder, varargin)
+%% Locate rendering data files in the given folder.
+%
+% renderings = rtbFindRenderings(rootFolder) scans the given rootFolder
+% recursively for rendering data files.  Returns a struct array of
+% rendering records, one for each data file found.
+%
+% rtbFindRenderings( ... 'filter', filter) uses the given regular
+% expression to filter the results.  Only data files whose full paths match
+% the expression will be compared.  The default is '\.mat$', look for any
+% .mat files.
+%
+% rtbFindRenderings( ... 'renderingsFolderName', renderingsFolderName) uses
+% the given renderingsFolderName to locate renderings withing various
+% subfolders of the given rootFolder.  The default is "renderings", which
+% is conisitent with the conventions established by rtbWorkingFolder() and
+% rtbBatchRender().
+%
+% Typical subfolders of rootFolder would look like these:
+%   rootFolder/rtbMakeMaterialSphereBumps/renderings/Mitsuba/materialSphereMetal.mat
+%   rootFolder/rtbMakeCoordinatesTest/renderings/PBRT/scene-003.mat
+%
+% These paths are parsed, starting with the renderingsFolderName, in this
+% case "renderings".  The folder above "renderings" is treated as the name
+% of the example or recipe that produced the rendering.  The folder below
+% "renderings" is treated as the name of the renderer.  The file name is
+% scanned for either a name, like "materialSphereMetal", or a sequence
+% number, like 3.
+%
+%%% RenderToolbox4 Copyright (c) 2012-2017 The RenderToolbox Team.
+%%% About Us://github.com/RenderToolbox/RenderToolbox4/wiki/About-Us
+%%% RenderToolbox4 is released under the MIT License.  See LICENSE file.
+
+parser = inputParser();
+parser.KeepUnmatched = true;
+parser.addRequired('rootFolder', @ischar);
+parser.addParameter('filter', '\.mat$', @ischar);
+parser.addParameter('renderingsFolderName', 'renderings', @ischar);
+parser.parse(rootFolder, varargin{:});
+rootFolder = parser.Results.rootFolder;
+filter = parser.Results.filter;
+renderingsFolderName = parser.Results.renderingsFolderName;
+
+files = rtbFindFiles('root', rootFolder, 'filter', filter);
+nFiles = numel(files);
+renderingsCell = cell(1, nFiles);
+for ff = 1:nFiles
+    fileName = files{ff};
+    
+    % break off the file name
+    [parentPath, imageName] = fileparts(fileName);
+    if numel(imageName) > 3
+        imageNumber = sscanf(imageName(end-3:end), '-%d');
+    end
+    
+    % look for the renderingsFolderName, like "renderings"
+    scanResult = textscan(parentPath, '%s', 'Delimiter', filesep());
+    pathParts = scanResult{1};
+    nPathParts = numel(pathParts);
+    isRenderingsFolder = strcmp(pathParts, renderingsFolderName);
+    if ~any(isRenderingsFolder)
+        continue;
+    end
+    renderingsFolderIndex = find(isRenderingsFolder, 1, 'last');
+    
+    % recipe name comes just before renderingsFolderName
+    recipeNameIndex = renderingsFolderIndex - 1;
+    recipeName = pathParts{recipeNameIndex};
+    
+    % renderer name comes just after renderingsFolderName, if any
+    rendererNameIndex = renderingsFolderIndex + 1;
+    if nPathParts >= rendererNameIndex
+        rendererName = pathParts{rendererNameIndex};
+    else
+        rendererName = '';
+    end
+    
+    % the path leading up to the recipeNamem, if any, is where it came from
+    sourceFolderIndices = 1:(recipeNameIndex-1);
+    if isempty(sourceFolderIndices)
+        sourceFolder = '';
+    else
+        sourceFolder = fullfile(pathParts{sourceFolderIndices});
+    end
+    
+    renderingsCell{ff} = rtbRenderingRecord( ...
+        'recipeName', recipeName, ...
+        'rendererName', rendererName, ...
+        'imageNumber', imageNumber, ...
+        'imageName', imageName, ...
+        'fileName', files{ff}, ...
+        'sourceFolder', sourceFolder);
+end
+renderings = [renderingsCell{:}];
diff --git a/Test/Interactive/Comparison/rtbPlotManyRecipeComparisons.m b/Test/Interactive/Comparison/rtbPlotManyRecipeComparisons.m
new file mode 100644
index 0000000000000000000000000000000000000000..72ef80c8d14779d9d8a13c8057b6acadc4073f32
--- /dev/null
+++ b/Test/Interactive/Comparison/rtbPlotManyRecipeComparisons.m
@@ -0,0 +1,134 @@
+function fig = rtbPlotManyRecipeComparisons(comparisons, varargin)
+%% Plot a many recipe comparisons from rtbCompareManyRecipes().
+%
+% fig = fig = rtbPlotManyRecipeComparisons(comparisons) makes a plot to
+% visualize the given struct array of comparison results, as produced by
+% rtbCompareManyRecipes().
+%
+%%% RenderToolbox4 Copyright (c) 2012-2017 The RenderToolbox Team.
+%%% About Us://github.com/RenderToolbox/RenderToolbox4/wiki/About-Us
+%%% RenderToolbox4 is released under the MIT License.  See LICENSE file.
+
+parser = inputParser();
+parser.KeepUnmatched = true;
+parser.addRequired('comparisons', @isstruct);
+parser.addParameter('fig', figure());
+parser.addParameter('figureWidth', 1000, @isnumeric);
+parser.addParameter('nRows', 25, @isnumeric);
+parser.addParameter('correlationMin', 0.8, @isnumeric);
+parser.addParameter('correlationStep', 0.05, @isnumeric);
+parser.addParameter('errorMax', 3, @isnumeric);
+parser.addParameter('errorStep', 0.5, @isnumeric);
+parser.parse(comparisons, varargin{:});
+comparisons = parser.Results.comparisons;
+fig = parser.Results.fig;
+figureWidth = parser.Results.figureWidth;
+nRows = parser.Results.nRows;
+correlationMin = parser.Results.correlationMin;
+correlationStep = parser.Results.correlationStep;
+errorMax = parser.Results.errorMax;
+errorStep = parser.Results.errorStep;
+
+
+%% Set up the figure.
+figureName = sprintf('Summary of %d rendering comparisons', ...
+    numel(comparisons));
+set(fig, ...
+    'Name', figureName, ...
+    'NumberTitle', 'off');
+
+position = get(fig, 'Position');
+if position(3) < figureWidth
+    position(3) = figureWidth;
+    set(fig, 'Position', position);
+end
+
+%% Summary of correlation coefficients.
+correlationTicks = correlationMin : correlationStep : 1;
+correlationTickLabels = num2cell(correlationTicks);
+correlationTickLabels{1} = '<=';
+correlation = [comparisons.corrcoef];
+correlation(correlation < correlationMin) = correlationMin;
+
+renderingsA = [comparisons.renderingA];
+names = {renderingsA.identifier};
+nLines = numel(names);
+ax(1) = subplot(1, 3, 2, ...
+    'Parent', fig, ...
+    'YTick', 1:nLines, ...
+    'YTickLabel', names, ...
+    'YGrid', 'on', ...
+    'XLim', [correlationTicks(1), correlationTicks(end)], ...
+    'XTick', correlationTicks, ...
+    'XTickLabel', correlationTickLabels);
+line(correlation, 1:nLines, ...
+    'Parent', ax(1), ...
+    'LineStyle', 'none', ...
+    'Marker', 'o', ...
+    'Color', [0 0 1])
+xlabel(ax(1), 'correlation');
+
+
+%% Overall title.
+name = sprintf('%s vs %s', ...
+    comparisons(1).renderingA.sourceFolder, ...
+    comparisons(1).renderingB.sourceFolder);
+title(ax(1), name, 'Interpreter', 'none');
+
+
+%% Summary of mean and max subpixel differences.
+errorTicks = 0 : errorStep : errorMax;
+errorTickLabels = num2cell(errorTicks);
+errorTickLabels{end} = '>=';
+
+relNormDiff = [comparisons.relNormDiff];
+maxes = [relNormDiff.max];
+means = [relNormDiff.mean];
+maxes(maxes > errorMax) = errorMax;
+means(means > errorMax) = errorMax;
+ax(2) = subplot(1, 3, 3, ...
+    'Parent', fig, ...
+    'YTick', 1:nLines, ...
+    'YTickLabel', 1:nLines, ...
+    'YAxisLocation', 'right', ...
+    'YGrid', 'on', ...
+    'XLim', [errorTicks(1), errorTicks(end)], ...
+    'XTick', errorTicks, ...
+    'XTickLabel', errorTickLabels);
+line(maxes, 1:nLines, ...
+    'Parent', ax(2), ...
+    'LineStyle', 'none', ...
+    'Marker', '+', ...
+    'Color', [1 0 0])
+line(means, 1:nLines, ...
+    'Parent', ax(2), ...
+    'LineStyle', 'none', ...
+    'Marker', 'o', ...
+    'Color', [0 0 0])
+legend(ax(2), 'max', 'mean', 'Location', 'northeast');
+xlabel(ax(2), 'relative diff');
+
+
+%% Let the user scroll both axes at the same time.
+scrollerData.axes = ax;
+scrollerData.nLinesAtATime = nRows;
+scroller = uicontrol( ...
+    'Parent', fig, ...
+    'Units', 'normalized', ...
+    'Position', [.95 0 .05 1], ...
+    'Callback', @scrollAxes, ...
+    'Min', 1, ...
+    'Max', max(2, nLines), ...
+    'Value', nLines, ...
+    'Style', 'slider', ...
+    'SliderStep', [1 2], ...
+    'UserData', scrollerData);
+scrollAxes(scroller, []);
+
+
+%% Scroll summary axes together.
+function scrollAxes(object, event)
+scrollerData = get(object, 'UserData');
+topLine = get(object, 'Value');
+yLimit = topLine + [-scrollerData.nLinesAtATime 1];
+set(scrollerData.axes, 'YLim', yLimit);
diff --git a/Test/Interactive/Comparison/rtbPlotRenderingComparison.m b/Test/Interactive/Comparison/rtbPlotRenderingComparison.m
new file mode 100644
index 0000000000000000000000000000000000000000..185bb1e0d2d19a6223f5ecdb180f4d8c78b38a75
--- /dev/null
+++ b/Test/Interactive/Comparison/rtbPlotRenderingComparison.m
@@ -0,0 +1,64 @@
+function fig = rtbPlotRenderingComparison(comparison, varargin)
+%% Plot a rendering comparison from rtbCompareRenderings().
+%
+% fig = rtbPlotRenderingComparison(comparison) makes a plot to visualize
+% the given struct of comparison results, as produced by
+% rtbCompareRenderings().
+%
+%%% RenderToolbox4 Copyright (c) 2012-2017 The RenderToolbox Team.
+%%% About Us://github.com/RenderToolbox/RenderToolbox4/wiki/About-Us
+%%% RenderToolbox4 is released under the MIT License.  See LICENSE file.
+
+parser = inputParser();
+parser.KeepUnmatched = true;
+parser.addRequired('comparison', @isstruct);
+parser.addParameter('isScale', true, @islogical);
+parser.addParameter('toneMapFactor', 0, @isnumeric);
+parser.addParameter('fig', figure());
+parser.parse(comparison, varargin{:});
+comparison = parser.Results.comparison;
+isScale = parser.Results.isScale;
+toneMapFactor = parser.Results.toneMapFactor;
+fig = parser.Results.fig;
+
+
+%% Compute RGB images for viewing.
+S = comparison.samplingA;
+rgbA = rtbMultispectralToSRGB(comparison.A, S, ...
+    'toneMapFactor', toneMapFactor, 'isScale', isScale);
+rgbB = rtbMultispectralToSRGB(comparison.B, S, ...
+    'toneMapFactor', toneMapFactor, 'isScale', isScale);
+rgbAminusB = rtbMultispectralToSRGB(comparison.aMinusB, S, ...
+    'toneMapFactor', toneMapFactor, 'isScale', isScale);
+rgbBminusA = rtbMultispectralToSRGB(comparison.bMinusA, S, ...
+    'toneMapFactor', toneMapFactor, 'isScale', isScale);
+
+
+%% Make the plot.
+name = sprintf('%s isScale %d toneMapFactor %.2f', ...
+    comparison.renderingA.identifier, ...
+    isScale, ...
+    toneMapFactor);
+set(fig, ...
+    'Name', name, ...
+    'NumberTitle', 'off');
+
+ax = subplot(2, 2, 2, 'Parent', fig);
+imshow(uint8(rgbA), 'Parent', ax);
+title(ax, 'A');
+xlabel(ax, comparison.renderingA.sourceFolder, ...
+    'Interpreter', 'none');
+
+ax = subplot(2, 2, 3, 'Parent', fig);
+imshow(uint8(rgbB), 'Parent', ax);
+title(ax, 'B');
+xlabel(ax, comparison.renderingB.sourceFolder, ...
+    'Interpreter', 'none');
+
+ax = subplot(2, 2, 1, 'Parent', fig);
+imshow(uint8(rgbAminusB), 'Parent', ax);
+title(ax, 'A - B');
+
+ax = subplot(2, 2, 4, 'Parent', fig);
+imshow(uint8(rgbBminusA), 'Parent', ax);
+title(ax, 'B - A');
diff --git a/Test/Interactive/Comparison/rtbRenderingRecord.m b/Test/Interactive/Comparison/rtbRenderingRecord.m
new file mode 100644
index 0000000000000000000000000000000000000000..db000e3dc45b2db96ad0e1eb4d93e7a6ee8e8863
--- /dev/null
+++ b/Test/Interactive/Comparison/rtbRenderingRecord.m
@@ -0,0 +1,49 @@
+function record = rtbRenderingRecord(varargin)
+% Make a well-formed struct to represent a rendering data file.
+%
+% The idea is to represent a rendering result using a consistent
+% struct format.  This will make it easier to compare sets of renderings
+% because we can compare rendering records for equality without worrying
+% about fussy syntax details of file names and paths.
+%
+% record = rtbRenderingRecord() creates a placeholder record with the
+% correct fields.
+%
+% record = rtbRenderingRecord( ... name, value) fills in the record with
+% fields based on the given names-value pairs.  Unrecognized names
+% will be ignored.
+%
+%%% RenderToolbox4 Copyright (c) 2012-2017 The RenderToolbox Team.
+%%% About Us://github.com/RenderToolbox/RenderToolbox4/wiki/About-Us
+%%% RenderToolbox4 is released under the MIT License.  See LICENSE file.
+
+parser = inputParser();
+parser.KeepUnmatched = true;
+parser.addParameter('recipeName', '', @ischar);
+parser.addParameter('rendererName', '', @ischar);
+parser.addParameter('imageNumber', [], @isnumeric);
+parser.addParameter('imageName', '',@ischar);
+parser.addParameter('fileName', '', @ischar);
+parser.addParameter('sourceFolder', '', @ischar);
+parser.parse(varargin{:});
+
+% let the parser do most of the work
+record = parser.Results;
+
+% format an identifier useful for comparing records with eg setdiff()
+recipeName = record.recipeName;
+if strncmp(recipeName, 'rtb', 3)
+    recipeName = recipeName(4:end);
+end
+
+if isempty(record.imageNumber)
+    record.identifier = sprintf('%s-%s-%s', ...
+        recipeName, ...
+        record.rendererName, ...
+        record.imageName);
+else
+    record.identifier = sprintf('%s-%s-%d', ...
+        recipeName, ...
+        record.rendererName, ...
+        record.imageNumber);
+end
diff --git a/Test/Interactive/Comparison/rtbRunEpicComparison.m b/Test/Interactive/Comparison/rtbRunEpicComparison.m
new file mode 100644
index 0000000000000000000000000000000000000000..5ea42c274ae7a849ec309c9de81cfe21a8fb674c
--- /dev/null
+++ b/Test/Interactive/Comparison/rtbRunEpicComparison.m
@@ -0,0 +1,145 @@
+function [comparisons, matchInfo, figs] = rtbRunEpicComparison(folderA, folderB, varargin)
+%% Compare sets of renderings for similarity.
+%
+% comparisons = rtbRunEpicComparison(folderA, folderB) locates renderings
+% in folderA and folderB, compares pairs of renderings found between the
+% two folders, and plots a summary of the comparisons.  Returns a struct
+% array of comparison results, one for each pair of renderings.
+%
+% Also returns a struct array of info about how renderings were matched
+% between folderA and folderB, including renderings from each folder that
+% were unmatched.
+%
+% Also returns an array of figure handles for visualizations of the
+% comparison results.
+%
+% rtbRunEpicComparison( ... 'plotSummary', plotSummary) whether to create a
+% plot summarizing the overall comparison results.  The default is true,
+% make a summary plot.
+%
+% rtbRunEpicComparison( ... 'closeSummary', closeSummary) whether to create
+% a close the overalls summary plot when done.  This is useful when you
+% specify a figureFolder, where the summary plot can be saved to disk.  the
+% default is false, don't close the summary figure.
+%
+% rtbRunEpicComparison( ... 'plotImages', plotImages) whether to create a
+% plot showing images and difference images for each pair of renderings.
+% The default is false, don't show the images for each pair.
+%
+% rtbRunEpicComparison( ... 'closeImages', closeImages) whether to close
+% the image plot for each pair of renderings when done.  This is useful
+% when you specify a figureFolder, where the images can be saved to disk.
+% The default is true, do close the image figures.
+%
+% rtbRunEpicComparison( ... 'figureFolder', figureFolder) specify a folder
+% where generated plots should be saved to disk, as a .fig file and as a
+% .png file.  The default is '', don't save figures to disk.
+%
+%%% RenderToolbox4 Copyright (c) 2012-2017 The RenderToolbox Team.
+%%% About Us://github.com/RenderToolbox/RenderToolbox4/wiki/About-Us
+%%% RenderToolbox4 is released under the MIT License.  See LICENSE file.
+
+parser = inputParser();
+parser.addRequired('folderA', @ischar);
+parser.addRequired('folderB', @ischar);
+parser.addParameter('plotSummary', true, @islogical);
+parser.addParameter('closeSummary', false, @islogical);
+parser.addParameter('plotImages', true, @islogical);
+parser.addParameter('closeImages', false, @islogical);
+parser.addParameter('figureFolder', '', @ischar);
+parser.addParameter('summaryName', 'epic-summary', @ischar);
+parser.parse(folderA, folderB, varargin{:});
+folderA = parser.Results.folderA;
+folderB = parser.Results.folderB;
+plotSummary = parser.Results.plotSummary;
+closeSummary = parser.Results.closeSummary;
+plotImages = parser.Results.plotImages;
+closeImages = parser.Results.closeImages;
+figureFolder = parser.Results.figureFolder;
+summaryName = parser.Results.summaryName;
+
+figs = [];
+
+
+%% Run the grand comparison.
+[comparisons, matchInfo] = rtbCompareManyRecipes(folderA, folderB, ...
+    varargin{:});
+
+
+%% Sort the summary by size of error.
+goodComparisons = comparisons([comparisons.isGoodComparison]);
+relNormDiff = [goodComparisons.relNormDiff];
+errorStat = [relNormDiff.max];
+[~, order] = sort(errorStat);
+goodComparisons = goodComparisons(order);
+
+
+%% Plot the summary.
+if plotSummary
+    summaryFig = rtbPlotManyRecipeComparisons(goodComparisons, ...
+        varargin{:});
+    
+    if ~isempty(figureFolder);
+        imageFileName = fullfile(figureFolder, summaryName);
+        saveFigure(summaryFig, imageFileName);
+    end
+    
+    if closeSummary
+        close(summaryFig);
+    else
+        figs = [figs summaryFig];
+    end
+end
+
+
+%% Plot the detail images for each rendering.
+if plotImages
+    nComparisons = numel(goodComparisons);
+    imageFigs = cell(1, nComparisons);
+    for cc = 1:nComparisons
+        imageFig = rtbPlotRenderingComparison(goodComparisons(cc), ...
+            varargin{:});
+        
+        if ~isempty(figureFolder);
+            identifier = goodComparisons(cc).renderingA.identifier;
+            imageFileName = fullfile(figureFolder, identifier);
+            saveFigure(imageFig, imageFileName);
+        end
+        
+        if closeImages
+            close(imageFig);
+        else
+            imageFigs{cc} = imageFig;
+        end
+    end
+    figs = [figs imageFigs{:}];
+end
+
+
+%% Save a figure to file, watch out for things like uicontrols.
+function saveFigure(fig, fileName)
+
+% flush pending drawing commands
+drawnow();
+
+% hide uicontrols, which can't always be saved
+controls = findobj(fig, 'Type', 'uicontrol');
+set(controls, 'Visible', 'off');
+
+% make sure output location exists
+[filePath, fileName] = fileparts(fileName);
+if 7 ~= exist(filePath, 'dir')
+    mkdir(filePath);
+end
+
+% save a png and a figure
+figName = fullfile(filePath, [fileName '.fig']);
+saveas(fig, figName, 'fig');
+
+pngName = fullfile(filePath, [fileName '.png']);
+set(fig, 'PaperPositionMode', 'auto');
+saveas(fig, pngName, 'png');
+
+% restore uicontrols
+set(controls, 'Visible', 'on');
+
diff --git a/Test/Interactive/rtbCompareAllExampleScenes.m b/Test/Interactive/rtbCompareAllExampleScenes.m
deleted file mode 100644
index e0ec06ba9f59256a80f9b0c5fef489b34c1ec45c..0000000000000000000000000000000000000000
--- a/Test/Interactive/rtbCompareAllExampleScenes.m
+++ /dev/null
@@ -1,565 +0,0 @@
-function [matchInfo, unmatchedA, unmatchedB] = rtbCompareAllExampleScenes(workingFolderA, workingFolderB, varargin)
-%% Compare recipe renderings that were generated at different times.
-%
-% [matchInfo, unmatchedA, unmatchedB] = rtbCompareAllExampleScenes(workingFolderA, workingFolderB)
-% Finds 2 sets of rendering outputs: set A includes renderings located
-% under the given workingFolderA, set B includes renderings located
-% under workingFolderB.  Attempts to match up data files from both sets,
-% based on recipe names and renderer names.  Computes comparison statistics
-% and shows difference images for each matched pair.
-%
-% Data sets must use a particular folder structure, consistent with
-% rtbWorkingFolder().  For each rendering data file, the expected path is:
-%	workingFolder/recipeName/rendererName/renderings/fileName.mat
-%
-% where:
-%   - workingFolder is either workingFolderA or workingFolderB
-%	- recipeName must be the name of a recipe such as "MakeDragon"
-%	- rendererName must be the name of a renderer, like "PBRT" or "Mitsuba"
-%	- fileName must be the name of a multi-spectral data file, such as "Dragon-001"
-%
-% rtbCompareAllExampleScenes( ... 'filterExpression', filterExpression)
-% uses the given regular expression filterExpr'/home/ben/Desktop/testA'ession to select file paths.
-% Only data files whose paths match the expression will be compared.  The
-% default is to do no such filtering.
-%
-% rtbCompareAllExampleScenes( ... 'visualize', visualize) specifies the
-% level of visualization to do during comparinsons.  The options are:
-%   - 0 -- don't plot anything
-%   - 1 -- (default) plot a summary figure at the end
-%   - 2 -- plot a summary figure at the end and a detail figure for each comparison
-%
-% The summary figure will contain two plots:
-%
-% A "correlation" plot will show this correlation betweeen paired
-% multi-spectral images, with each image simply treated as a matrix of
-% numbers.
-%
-% A "relative diff" plot will show the relative differences between paired
-% pixel components, where raw diff is
-%   diff = |a-b|/a, where a/max(a) > .2
-% The diff is only calculated when the "a" value is not small, to avoid
-% unfair comparison due to large denominator.  For each pair of images, the
-% plot will show the mean and max of the diffs from all pixel components.
-%
-% rtbCompareAllExampleScenes( ... 'figureFolder', figureFolder) specifies
-% an output folder where to save figures used for visualization.  The
-% default is rtbWorkingFolder().
-%
-% This function is intended to help validate RenderToolbox installations
-% and detect bugs in the RenderToolbox code.  A potential use would
-% compare renderings produced locally with archived renderings located at
-% Amazon S3.  For example:
-%   % produce renderings locally
-%   rtbTestAllExampleScenes('my/local/renderings');
-%
-%   % download archived renderings to 'my/local/archive'
-%
-%   % summarize local vs archived renderings
-%   workingFolderA = 'my/local/renderings/data';
-%   workingFolderA = 'my/local/archive/data';
-%   visualize = 1;
-%   matchInfo = rtbCompareAllExampleScenes(workingFolderA, workingFolderB, 'visualize', visualize);
-%
-% Returns a struct array of info about each matched pair, including file
-% names and differneces between multispectral images (A minus B).
-%
-% Also returns a cell array of paths for files in set A that did not match
-% any of the files in set B.  Likewise, returns a cell array of paths for
-% files in set B that did not match any of the files in set A.
-%
-%%% RenderToolbox4 Copyright (c) 2012-2016 The RenderToolbox Team.
-%%% About Us://github.com/RenderToolbox/RenderToolbox4/wiki/About-Us
-%%% RenderToolbox4 is released under the MIT License.  See LICENSE file.
-
-parser = inputParser();
-parser.addRequired('workingFolderA', @ischar);
-parser.addRequired('workingFolderB', @ischar);
-parser.addParameter('filterExpression', '', @ischar);
-parser.addParameter('visualize', 1, @isnumeric);
-parser.addParameter('figureFolder', fullfile(rtbWorkingFolder(), 'comparisons'), @ischar);
-parser.parse(workingFolderA, workingFolderB, varargin{:});
-workingFolderA = parser.Results.workingFolderA;
-workingFolderB = parser.Results.workingFolderB;
-filterExpression = parser.Results.filterExpression;
-visualize = parser.Results.visualize;
-figureFolder = parser.Results.figureFolder;
-
-matchInfo = [];
-unmatchedA = {};
-unmatchedB = {};
-
-% find .mat files for sets A and B
-fileFilter = [filterExpression '[^\.]*\.mat$'];
-filesA = rtbFindFiles('root', workingFolderA, 'filter', fileFilter);
-filesB = rtbFindFiles('root', workingFolderB, 'filter', fileFilter);
-
-if isempty(filesA)
-    fprintf('Found no files for set A in: %s\n', workingFolderA);
-    return;
-end
-
-if isempty(filesB)
-    fprintf('Found no files for set B in: %s\n', workingFolderB);
-    return;
-end
-
-% parse out expected path parts for each file
-infoA = scanDataPaths(filesA);
-infoB = scanDataPaths(filesB);
-
-% report unmatched files
-matchTokensA = {infoA.matchToken};
-matchTokensB = {infoB.matchToken};
-[~, indexA, indexB] = intersect( ...
-    matchTokensA, matchTokensB, 'stable');
-[~, unmatchedIndex] = setdiff(matchTokensA, matchTokensB);
-unmatchedA = filesA(unmatchedIndex);
-[~, unmatchedIndex] = setdiff(matchTokensB, matchTokensA);
-unmatchedB = filesB(unmatchedIndex);
-
-if isempty(indexA) || isempty(indexB)
-    fprintf('Could not find any file matches.\n');
-    return;
-end
-
-% allocate an info struct for image comparisons
-filesA = {infoA.original};
-filesB = {infoB.original};
-matchInfo = struct( ...
-    'fileA', filesA(indexA), ...
-    'fileB', filesB(indexB), ...
-    'workingFolderA', workingFolderA, ...
-    'workingFolderB', workingFolderB, ...
-    'relativePathA', {infoA(indexA).relativePath}, ...
-    'relativePathB', {infoB(indexB).relativePath}, ...
-    'matchTokenA', matchTokensA(indexA), ...
-    'matchTokenB', matchTokensB(indexB), ...
-    'samplingA', [], ...
-    'samplingB', [], ...
-    'denominatorThreshold', 0.2, ...
-    'subpixelsA', [], ...
-    'subpixelsB', [], ...
-    'normA', [], ...
-    'normB', [], ...
-    'normDiff', [], ...
-    'absNormDiff', [], ...
-    'relNormDiff', [], ...
-    'corrcoef', nan, ...
-    'isGoodComparison', false, ...
-    'detailFigure', nan, ...
-    'error', '');
-
-% any comparisons to make?
-nMatches = numel(matchInfo);
-if nMatches > 0
-    fprintf('Found %d matched pairs of data files.\n', nMatches);
-    fprintf('Some of these might not contain images and would be skipped.\n');
-else
-    fprintf('Found no matched pairs.\n');
-    return;
-end
-fprintf('\n')
-
-nUnmatchedA = numel(unmatchedA);
-if nUnmatchedA > 0
-    fprintf('%d data files in set A had no match in set B:\n', nUnmatchedA);
-    for ii = 1:nUnmatchedA
-        fprintf('  %s\n', unmatchedA{ii});
-    end
-end
-fprintf('\n')
-
-nUnmatchedB = numel(unmatchedB);
-if nUnmatchedB > 0
-    fprintf('%d data files in set B had no match in set A:\n', nUnmatchedB);
-    for ii = 1:nUnmatchedB
-        fprintf('  %s\n', unmatchedB{ii});
-    end
-end
-fprintf('\n')
-
-% compare matched images!
-for ii = 1:nMatches
-    fprintf('%d of %d: %s\n', ii, nMatches, matchInfo(ii).matchTokenA);
-    
-    % load data
-    dataA = load(matchInfo(ii).fileA);
-    dataB = load(matchInfo(ii).fileB);
-    
-    % do we have images?
-    hasImageA = isfield(dataA, 'multispectralImage');
-    hasImageB = isfield(dataB, 'multispectralImage');
-    if hasImageA
-        if hasImageB
-            % each has an image -- proceed
-        else
-            matchInfo(ii).error = ...
-                'Data file A has a multispectralImage but B does not!';
-            disp(matchInfo(ii).error);
-            continue;
-        end
-    else
-        if hasImageB
-            matchInfo(ii).error = ...
-                'Data file B has a multispectralImage but A does not!';
-            disp(matchInfo(ii).error);
-            continue;
-        else
-            matchInfo(ii).error = ...
-                'Neither data file A nor B has a multispectralImage -- skipping.';
-            continue;
-        end
-    end
-    multispectralA = dataA.multispectralImage;
-    multispectralB = dataB.multispectralImage;
-    
-    % do image dimensions agree?
-    if ~isequal(size(multispectralA, 1), size(multispectralB, 1)) ...
-            || ~isequal(size(multispectralA, 2), size(multispectralB, 2))
-        matchInfo(ii).error = ...
-            sprintf('Image A[%s] is not the same size as image B[%s].', ...
-            num2str(size(multispectralA)), num2str(size(multispectralB)));
-        disp(matchInfo(ii).error);
-        continue;
-    end
-    
-    % do we have spectral sampling?
-    hasSamplingA = isfield(dataA, 'S');
-    hasSamplingB = isfield(dataB, 'S');
-    if hasSamplingA
-        if hasSamplingB
-            % each has a sampling "S" -- proceed
-        else
-            matchInfo(ii).error = ...
-                'Data file A has a spectral sampling "S" but B does not!';
-            disp(matchInfo(ii).error);
-            continue;
-        end
-    else
-        if hasSamplingB
-            matchInfo(ii).error = ...
-                'Data file B has a spectral sampling "S" but A does not!';
-            disp(matchInfo(ii).error);
-            continue;
-        else
-            matchInfo(ii).error = ...
-                'Neither data file A nor B has spectral sampling "S" -- skipping.';
-            continue;
-        end
-    end
-    matchInfo(ii).samplingA = dataA.S;
-    matchInfo(ii).samplingB = dataB.S;
-    
-    % do spectral samplings agree?
-    if ~isequal(dataA.S, dataB.S)
-        matchInfo(ii).error = ...
-            sprintf('Spectral sampling A[%s] is not the same as B[%s].', ...
-            num2str(dataA.S), num2str(dataB.S));
-        disp(matchInfo(ii).error);
-        % proceed with comparison, despite sampling mismatch
-    end
-    
-    % tolerate different sectral sampling depths
-    [A, B] = truncatePlanes(multispectralA, multispectralB, dataA.S, dataB.S);
-    
-    % comparison passes all sanity checks
-    matchInfo(ii).isGoodComparison = true;
-    
-    % compute per-pixel component difference stats
-    normA = A / max(A(:));
-    normB = B / max(B(:));
-    normDiff = normA - normB;
-    absNormDiff = abs(normDiff);
-    relNormDiff = absNormDiff ./ normA;
-    cutoff = matchInfo(ii).denominatorThreshold;
-    relNormDiff(normA < cutoff) = nan;
-    
-    % summarize differnece stats
-    matchInfo(ii).subpixelsA = summarizeData(A);
-    matchInfo(ii).subpixelsB = summarizeData(B);
-    matchInfo(ii).normA = summarizeData(normA);
-    matchInfo(ii).normB = summarizeData(normB);
-    matchInfo(ii).normDiff = summarizeData(normDiff);
-    matchInfo(ii).absNormDiff = summarizeData(absNormDiff);
-    matchInfo(ii).relNormDiff = summarizeData(relNormDiff);
-    
-    % compute correlation among pixel components
-    r = corrcoef(A(:), B(:));
-    matchInfo(ii).corrcoef = r(1, 2);
-    
-    % plot difference image?
-    if visualize > 1
-        f = showDifferenceImage(matchInfo(ii), A, B);
-        matchInfo(ii).detailFigure = f;
-        
-        % save detail figure to disk
-        drawnow();
-        [imagePath, imageName] = fileparts(matchInfo(ii).relativePathA);
-        imageCompPath = fullfile(figureFolder, imagePath);
-        if ~exist(imageCompPath, 'dir')
-            mkdir(imageCompPath);
-        end
-        figName = fullfile(imageCompPath, [imageName '.fig']);
-        saveas(f, figName, 'fig');
-        pngName = fullfile(imageCompPath, [imageName '.png']);
-        set(f, 'PaperPositionMode', 'auto');
-        saveas(f, pngName, 'png');
-        
-        close(f);
-    end
-end
-
-nComparisons = sum([matchInfo.isGoodComparison]);
-nSkipped = nMatches - nComparisons;
-fprintf('Compared %d pairs of data files.\n', nComparisons);
-fprintf('Skipped %d pairs of data files, which is not necessarily a problem.\n', nSkipped);
-
-% plot a grand summary?
-if visualize > 0
-    f = showDifferenceSummary(matchInfo);
-    
-    % save summary figure to disk
-    if ~exist(figureFolder, 'dir')
-        mkdir(figureFolder);
-    end
-    imageName = sprintf('%s-summary', mfilename());
-    figName = fullfile(figureFolder, [imageName '.fig']);
-    saveas(f, figName, 'fig');
-    
-    % some platforms can't pring uicontrols
-    controls = findobj(f, 'Type', 'uicontrol');
-    set(controls, 'Visible', 'off');
-    pngName = fullfile(figureFolder, [imageName '.png']);
-    saveas(f, pngName, 'png');
-    set(controls, 'Visible', 'on');
-end
-
-if visualize > 1
-    fprintf('\nSee comparison images saved in:\n  %s\n', figureFolder);
-end
-
-
-% Scan paths for expected parts:
-%   root/recipeName/subfolderName/rendererName/fileName.extension
-function info = scanDataPaths(paths)
-n = numel(paths);
-rootPath = cell(1, n);
-relativePath = cell(1, n);
-recipeName = cell(1, n);
-subfolderName = cell(1, n);
-rendererName = cell(1, n);
-fileName = cell(1, n);
-fileNumber = cell(1, n);
-hasNumber = false(1, n);
-matchToken = cell(1, n);
-for ii = 1:n
-    % break off the file name
-    [parentPath, baseName, extension] = fileparts(paths{ii});
-    fileName{ii} = [baseName extension];
-    if numel(baseName) >= 4
-        fileNumber{ii} = sscanf(baseName(end-3:end), '-%d');
-    end
-    hasNumber(ii) = ~isempty(fileNumber{ii});
-    
-    % break out subfolder names
-    scanResult = textscan(parentPath, '%s', 'Delimiter', filesep());
-    tokens = scanResult{1};
-    
-    % is there a renderer folder?
-    if any(strcmp(tokens{end}, {'PBRT', 'Mitsuba'}))
-        rendererName{ii} = tokens{end};
-        subfolderNameIndex = numel(tokens) - 1;
-    else
-        rendererName{ii} = '';
-        subfolderNameIndex = numel(tokens);
-    end
-    
-    % get the named subfolder name
-    subfolderName{ii} = tokens{subfolderNameIndex};
-    
-    % get the recipe name
-    recipeName{ii} = tokens{subfolderNameIndex-1};
-    
-    % get the root path
-    rootPath{ii} = fullfile(tokens{1:subfolderNameIndex-2});
-    
-    % build the rootless relative path
-    relativePath{ii} = fullfile(recipeName{ii}, subfolderName{ii}, ...
-        rendererName{ii}, fileName{ii});
-    
-    % build a token for matching across file sets
-    if strncmp(recipeName{ii}, 'rtb', 3)
-        nameBase = recipeName{ii}(4:end);
-    else
-        nameBase = recipeName{ii};
-    end
-    matchTokenBase = [nameBase '-' subfolderName{ii} '-' rendererName{ii} '-'];
-    if hasNumber(ii)
-        matchToken{ii} = [matchTokenBase sprintf('%03d', fileNumber{ii})];
-    else
-        matchToken{ii} = [matchTokenBase baseName];
-    end
-end
-
-info = struct( ...
-    'original', paths, ...
-    'fileName', fileName, ...
-    'fileNumber', fileNumber, ...
-    'hasNumber', hasNumber, ...
-    'rendererName', rendererName, ...
-    'recipeName', recipeName, ...
-    'subfolderName', subfolderName, ...
-    'rootPath', rootPath, ...
-    'relativePath', relativePath, ...
-    'matchToken', matchToken);
-
-
-% Show sRGB images and sRGB difference images
-function f = showDifferenceImage(info, A, B)
-
-% make SRGB images
-[A, B, S] = truncatePlanes(A, B, info.samplingA, info.samplingB);
-isScale = true;
-toneMapFactor = 0;
-imageA = rtbMultispectralToSRGB(A, S, 'toneMapFactor', toneMapFactor, 'isScale', isScale);
-imageB = rtbMultispectralToSRGB(B, S, 'toneMapFactor', toneMapFactor, 'isScale', isScale);
-imageAB = rtbMultispectralToSRGB(A-B, S, 'toneMapFactor', toneMapFactor, 'isScale', isScale);
-imageBA = rtbMultispectralToSRGB(B-A, S, 'toneMapFactor', toneMapFactor, 'isScale', isScale);
-
-% show images in a new figure
-name = sprintf('sRGB scaled: %s', info.matchTokenA);
-f = figure('Name', name, 'NumberTitle', 'off');
-
-ax = subplot(2, 2, 2, 'Parent', f);
-imshow(uint8(imageA), 'Parent', ax);
-title(ax, ['A: ' info.workingFolderA]);
-
-ax = subplot(2, 2, 3, 'Parent', f);
-imshow(uint8(imageB), 'Parent', ax);
-title(ax, ['B: ' info.workingFolderB]);
-
-ax = subplot(2, 2, 1, 'Parent', f);
-imshow(uint8(imageAB), 'Parent', ax);
-title(ax, 'Difference: A - B');
-
-ax = subplot(2, 2, 4, 'Parent', f);
-imshow(uint8(imageBA), 'Parent', ax);
-title(ax, 'Difference: B - A');
-
-
-% Truncate spectral planes if one image has more planes.
-function [truncA, truncB, truncSampling] = truncatePlanes(A, B, samplingA, samplingB)
-nPlanes = min(samplingA(3), samplingB(3));
-truncSampling = [samplingA(1:2) nPlanes];
-truncA = A(:,:,1:nPlanes);
-truncB = B(:,:,1:nPlanes);
-
-
-% Show a summary of all difference images.
-function f = showDifferenceSummary(info)
-figureName = sprintf('A: %s vs B: %s', ...
-    info(1).workingFolderA, info(1).workingFolderB);
-f = figure('Name', figureName, 'NumberTitle', 'off');
-
-% summarize only fair comparisions
-goodInfo = info([info.isGoodComparison]);
-
-% sort the summary by size of error
-diffSummary = [goodInfo.relNormDiff];
-errorStat = [diffSummary.max];
-[~, order] = sort(errorStat);
-goodInfo = goodInfo(order);
-
-% summarize data correlation coefficients
-minCorr = 0.85;
-peggedCorr = 0.8;
-corrTicks = [peggedCorr minCorr:0.05:1];
-corrTickLabels = num2cell(corrTicks);
-corrTickLabels{1} = sprintf('<%.2f', minCorr);
-corr = [goodInfo.corrcoef];
-corr(corr < minCorr) = peggedCorr;
-
-names = {goodInfo.matchTokenA};
-nLines = numel(names);
-ax(1) = subplot(1, 3, 2, ...
-    'Parent', f, ...
-    'YTick', 1:nLines, ...
-    'YTickLabel', names, ...
-    'YGrid', 'on', ...
-    'XLim', [corrTicks(1), corrTicks(end)], ...
-    'XTick', corrTicks, ...
-    'XTickLabel', corrTickLabels);
-line(corr, 1:nLines, ...
-    'Parent', ax(1), ...
-    'LineStyle', 'none', ...
-    'Marker', 'o', ...
-    'Color', [0 0 1])
-title(ax(1), 'correlation');
-
-% summarize mean and max subpixel differences
-maxDiff = 2.5;
-peggedDiff = 3;
-diffTicks = [0:0.5:maxDiff peggedDiff];
-diffTickLabels = num2cell(diffTicks);
-diffTickLabels{end} = sprintf('>%.2f', maxDiff);
-
-diffSummary = [goodInfo.relNormDiff];
-maxes = [diffSummary.max];
-means = [diffSummary.mean];
-maxes(maxes > maxDiff) = peggedDiff;
-means(means > maxDiff) = peggedDiff;
-ax(2) = subplot(1, 3, 3, ...
-    'Parent', f, ...
-    'YTick', 1:nLines, ...
-    'YTickLabel', 1:nLines, ...
-    'YAxisLocation', 'right', ...
-    'YGrid', 'on', ...
-    'XLim', [diffTicks(1), diffTicks(end)], ...
-    'XTick', diffTicks, ...
-    'XTickLabel', diffTickLabels);
-line(maxes, 1:nLines, ...
-    'Parent', ax(2), ...
-    'LineStyle', 'none', ...
-    'Marker', '+', ...
-    'Color', [1 0 0])
-line(means, 1:nLines, ...
-    'Parent', ax(2), ...
-    'LineStyle', 'none', ...
-    'Marker', 'o', ...
-    'Color', [0 0 0])
-legend(ax(2), 'max', 'mean', 'Location', 'northeast');
-title(ax(2), 'relative diff');
-
-% let the user scroll both axes at the same time
-nLinesAtATime = 25;
-scrollerData.axes = ax;
-scrollerData.nLinesAtATime = nLinesAtATime;
-scroller = uicontrol( ...
-    'Parent', f, ...
-    'Units', 'normalized', ...
-    'Position', [.95 0 .05 1], ...
-    'Callback', @scrollSummaryAxes, ...
-    'Min', 1, ...
-    'Max', max(2, nLines), ...
-    'Value', nLines, ...
-    'Style', 'slider', ...
-    'SliderStep', [1 2], ...
-    'UserData', scrollerData);
-scrollSummaryAxes(scroller, []);
-
-
-% Summarize a distribuition of data with a struct of stats.
-function summary = summarizeData(data)
-finiteData = data(isfinite(data));
-summary.min = min(finiteData);
-summary.mean = mean(finiteData);
-summary.max = max(finiteData);
-
-
-% Scroll summary axes together.
-function scrollSummaryAxes(object, event)
-scrollerData = get(object, 'UserData');
-topLine = get(object, 'Value');
-yLimit = topLine + [-scrollerData.nLinesAtATime 1];
-set(scrollerData.axes, 'YLim', yLimit);
diff --git a/Test/Interactive/rtbTestAllExampleScenes.m b/Test/Interactive/rtbRunEpicTest.m
similarity index 93%
rename from Test/Interactive/rtbTestAllExampleScenes.m
rename to Test/Interactive/rtbRunEpicTest.m
index 12d549dfa9c7cc2b865d4f09a2e245f3cd73d438..68ba5ea8997032c0ed9af5247eb92f5e11dadcc3 100644
--- a/Test/Interactive/rtbTestAllExampleScenes.m
+++ b/Test/Interactive/rtbRunEpicTest.m
@@ -1,7 +1,7 @@
-function results = rtbTestAllExampleScenes(varargin)
+function results = rtbRunEpicTest(varargin)
 %% Run all "rtbMake..." scripts in the ExampleScenes/ folder.
 %
-% results = rtbTestAllExampleScenes() renders example scenes by invoking
+% results = rtbRunEpicTest() renders example scenes by invoking
 % all of the "rtbMake..." executive sripts found within the ExampleScenes/
 % folder
 %
@@ -19,11 +19,11 @@ function results = rtbTestAllExampleScenes(varargin)
 % have a name that that includes the name of this m-file, plus the date and
 % time.
 %
-% rtbTestAllExampleScenes( ... 'outputRoot', outputRoot) specifies the
+% rtbRunEpicTest( ... 'outputRoot', outputRoot) specifies the
 % working folder where to put rendering outputs.  The default is from
 % rtbDefaultHints().
 %
-% rtbTestAllExampleScenes( ... 'makeFunctions', makeFunctions) specifies a
+% rtbRunEpicTest( ... 'makeFunctions', makeFunctions) specifies a
 % cell array of executive functions to run.  The default is to search the
 % ExampleScenes/ folder for m-files that begin with "rtbMake".
 %
@@ -107,6 +107,7 @@ for ii = find(~isExampleSuccess)
     disp('----')
     fprintf('%d %s\n', ii, results(ii).makeFile);
     disp(results(ii).error)
+    disp(results(ii).error.message)
     disp(' ')
 end
 
diff --git a/Test/Interactive/rtbTestInstallation.m b/Test/Interactive/rtbTestInstallation.m
index 743efe03c5e76da43497957f12def047b3e82b34..ada27c2e7035d2605d8dd2a6ea5cb2ae05658f4a 100644
--- a/Test/Interactive/rtbTestInstallation.m
+++ b/Test/Interactive/rtbTestInstallation.m
@@ -34,6 +34,7 @@ doAll = parser.Results.doAll;
 renderResults = [];
 comparison = [];
 
+
 %% Check working folder for write permission.
 workingFolder = rtbWorkingFolder();
 fprintf('Checking working folder:\n');
@@ -64,31 +65,19 @@ fclose(fid);
 delete(testFile);
 fprintf('  OK.\n');
 
-%% Check for Docker, the preferred way to render.
-fprintf('Checking for Docker...\n');
-[status, result] = system('docker ps');
-if 0 == status
-    fprintf('  OK.\n');
-else
-    fprintf('  Could not invoke Docker: %s.\n', result);
-end
 
-%% Check for local install of Renderers.
-mitsuba = getpref('Mitsuba');
-if ismac()
-    checkExists(mitsuba.app, 'Checking for Mitsuba App...');
-else
-    checkExists(mitsuba.executable, 'Checking for Mitsuba Executable...');
+%% Check for native system libs and executables.
+[status, ~, advice] = rtbCheckNativeDependencies();
+if 0 ~= status
+    error(advice);
 end
 
-pbrt = getpref('PBRT');
-checkExists(pbrt.executable, 'Checking for PBRT Executable...');
 
 %% Render some example scenes.
 if doAll
     fprintf('\nTesting rendering with all example scripts.\n');
     fprintf('This might take a while.\n');
-    renderResults = rtbTestAllExampleScenes([], []);
+    renderResults = rtbRunEpicTest([], []);
     
 else
     testScenes = { ...
@@ -99,7 +88,7 @@ else
     
     fprintf('\nTesting rendering with %d example scripts.\n', numel(testScenes));
     fprintf('You should see several figures with rendered images.\n\n');
-    renderResults = rtbTestAllExampleScenes('makeFunctions', testScenes);
+    renderResults = rtbRunEpicTest('makeFunctions', testScenes);
     
 end
 
@@ -107,26 +96,15 @@ if all([renderResults.isSuccess])
     fprintf('\nYour RenderToolbox4 installation seems to be working!\n');
 end
 
+
 %% Compare renderings to reference renderings?
 if ~isempty(referenceRoot)
     localRoot = rtbWorkingFolder();
     fprintf('\nComparing local renderings\n  %s\n', localRoot);
     fprintf('with reference renderings\n  %s\n', referenceRoot);
     fprintf('You should see several more figures.\n\n');
-    comparison = rtbCompareAllExampleScenes(localRoot, referenceRoot, '', 2);
+    comparison = rtbRunEpicComparison(localRoot, referenceRoot, '', 2);
 else
     fprintf('\nNo referenceRoot provided.  Local renderings\n');
     fprintf('will not be compared with reference renderings.\n');
 end
-
-
-%% Check whether something exists and print messages.
-function exists = checkExists(filePath, message)
-fprintf('%s\n', message);
-exists = 0 ~= exist(filePath, 'dir') || 0 ~= exist(filePath, 'file');
-if exists
-    fprintf('  Found %s\n', filePath);
-    fprintf('  OK.\n');
-else
-    fprintf('  Could not find %s\n', filePath);
-end
diff --git a/rdt-config-render-toolbox.json b/rdt-config-render-toolbox.json
new file mode 100644
index 0000000000000000000000000000000000000000..5f415f000be90c33f13372f2e019b5b8704ce5cc
--- /dev/null
+++ b/rdt-config-render-toolbox.json
@@ -0,0 +1,8 @@
+{
+	"serverUrl": "http://52.32.77.154/",
+    "repositoryUrl": "http://52.32.77.154/repository/RenderToolbox/",
+	"username": "guest",
+	"password": "",
+    "repositoryName": "RenderToolbox"
+}
+
diff --git a/rtbCheckNativeDependencies.m b/rtbCheckNativeDependencies.m
new file mode 100644
index 0000000000000000000000000000000000000000..88b7093dd2017e8091e8826460a661870f1a6644
--- /dev/null
+++ b/rtbCheckNativeDependencies.m
@@ -0,0 +1,100 @@
+function [status, result, advice] = rtbCheckNativeDependencies()
+% Check whether required native dependencies are installed.
+%
+% [status, result, advice] = rtbCheckNativeDependencies() checks the local
+% system for native dependencies, like renderers and shared libraries.
+% Returns a status code which is non-zero if some dependency was missing.
+% Also returns a result, such as an error code about the missing
+% dependency.  Finally, returns a string with advice about how to obtain
+% a missing dependency, if any.
+%
+% [status, result, advice] = rtbCheckNativeDependencies()
+%
+%%% RenderToolbox4 Copyright (c) 2012-2017 The RenderToolbox Team.
+%%% About Us://github.com/RenderToolbox/RenderToolbox4/wiki/About-Us
+%%% RenderToolbox4 is released under the MIT License.  See LICENSE file.
+
+
+%% Check for OpenEXR library, also known as IlmImf.
+if ismac()
+    % assume homebrew
+    findLibCommand = 'brew list | grep openexr';
+else
+    findLibCommand = 'ldconfig -p | grep libIlmImf';
+end
+openExr = checkSystem('OpenEXR', ...
+    findLibCommand, ...
+    'It looks like the OpenEXR library is not installed.  Please visit http://www.openexr.com/.  You might also try "sudo apt-get install openexr" or similar.');
+
+
+%% OpenEXR is required.
+if 0 ~= openExr.status
+    status = openExr.status;
+    result = openExr.result;
+    advice = openExr.advice;
+    return;
+end
+
+
+%% Check for Docker, the preferred way to obtain renderers.
+docker = checkSystem('Docker', ...
+    'docker ps', ...
+    'It looks like Docker is not installed.  Please visit https://github.com/RenderToolbox/RenderToolbox4/wiki/Docker.');
+
+
+%% Docker can cover both renderers.
+if 0 == docker.status
+    status = 0;
+    result = 'Local dependencies were found.';
+    advice = '';
+    return;
+end
+
+
+%% Check for a local installation of the Mitsuba renderer.
+mitsuba = checkSystem('Mitsuba', ...
+    'which mitsuba', ...
+    'It looks like Mitsuba is not installed.  Please visit https://www.mitsuba-renderer.org/.  Or, consider installing Docker so that RenderToolbox can get Mitsuba for you.');
+
+
+%% Check for a local installation of the PBRT renderer.
+pbrt = checkSystem('PBRT', ...
+    'which pbrt', ...
+    'It looks like PBRT is not installed.  Please visit https://github.com/ydnality/pbrt-v2-spectral.  Or, consider installing Docker so that RenderToolbox can get PBRT for you.');
+
+
+%% Check for both renderers.
+if 0 ~= mitsuba.status
+    status = mitsuba.status;
+    result = mitsuba.result;
+    advice = mitsuba.advice;
+    return;
+end
+
+if 0 ~= pbrt.status
+    status = pbrt.status;
+    result = pbrt.result;
+    advice = pbrt.advice;
+    return;
+end
+
+
+%% Looks good from here.
+status = 0;
+result = 'Local dependencies were found.';
+advice = '';
+
+
+%% Check whether something exists and print messages.
+function info = checkSystem(name, command, advice)
+fprintf('Checking for %s:\n', name);
+fprintf('  %s\n', command);
+info.name = name;
+info.advice = advice;
+[info.status, result] = system(command);
+info.result = strtrim(result);
+if 0 == info.status
+    fprintf('  OK.\n');
+else
+    fprintf('  Not found.  Status %d, result <%s>.\n', info.status, info.result);
+end