diff --git a/CHANGELOG.md b/CHANGELOG.md
index 626de8e2138f353dc102dbb93124a096aa90465c..5f4fa6b8de5521de1aee1ac6f623327a730af463 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,12 +6,55 @@ Note that since we don't clearly distinguish between a public and private interf
 
 ## [Unreleased]
 
-- Fix defaultAttribs handling in Canvas3DContext.fromCanvas
-- Add custom labels to Confal pyramids
-- Improve naming of some internal types in Confal pyramids extension coordinate
-- Add example mmCIF file with categories necessary to display Confal pyramids
+- [Fix] Clone ``Canvas3DParams`` when creating a ``Canvas3D`` instance to prevent shared state between multiple instances
+- Add ``includeResidueTest`` option to ``alignAndSuperposeWithSIFTSMapping``
+- Add ``parentDisplay`` param for interactions representation.
 - [Experimental] Add support for PyMOL, VMD, and Jmol atom expressions in selection scripts
 
+## [v3.16.0] - 2022-08-25
+
+- Support ``globalColorParams`` and ``globalSymmetryParams`` in common representation params
+- Support ``label`` parameter in ``Viewer.loadStructureFromUrl``
+- Fix ``ViewportHelpContent`` Mouse Controls section
+
+## [v3.15.0] - 2022-08-23
+
+- Fix wboit in Safari >=15 (add missing depth renderbuffer to wboit pass)
+- Add 'Around Camera' option to Volume streaming
+- Avoid queuing more than one update in Volume streaming
+
+## [v3.14.0] - 2022-08-20
+
+- Expose inter-bonds compute params in structure
+- Improve performance of inter/intra-bonds compute
+- Fix defaultAttribs handling in Canvas3DContext.fromCanvas
+- Confal pyramids extension improvements
+    - Add custom labels to Confal pyramids
+    - Improve naming of some internal types in Confal pyramids extension coordinate
+    - Add example mmCIF file with categories necessary to display Confal pyramids
+    - Change the lookup logic of NtC steps from residues
+- Add support for download of gzipped files
+- Don't filter IndexPairBonds by element-based rules in MOL/SDF and MOL2 (without symmetry) models
+- Fix Glycam Saccharide Names used by default
+- Fix GPU surfaces rendering in Safari with WebGL2
+- Add ``fov`` (Field of View) Canvas3D parameter
+- Add ``sceneRadiusFactor`` Canvas3D parameter
+- Add background pass (skybox, image, horizontal/radial gradient)
+    - Set simple-settings presets via ``PluginConfig.Background.Styles``
+    - Example presets in new backgrounds extension
+    - Load skybox/image from URL or File (saved in session)
+    - Opacity, saturation, lightness controls for skybox/image
+    - Coverage (viewport or canvas) controls for image/gradient
+- [Breaking] ``AssetManager`` needs to be passed to various graphics related classes
+- Fix SSAO renderable initialization
+- Reduce number of webgl state changes
+    - Add ``viewport`` and ``scissor`` to state object
+    - Add ``hasOpaque`` to scene object
+- Handle edge cases where some renderables would not get (correctly) rendered
+    - Fix text background rendering for opaque text
+    - Fix helper scenes not shown when rendering directly to draw target
+- Fix ``CustomElementProperty`` coloring not working
+
 ## [v3.13.0] - 2022-07-24
 
 - Fix: only update camera state if manualReset is off (#494)
diff --git a/README.md b/README.md
index 10e926c509f71742f239fe3ccf7e064b7130e470..9125bac897959988a7bbb5405f62481fac94cf41 100644
--- a/README.md
+++ b/README.md
@@ -126,7 +126,7 @@ and navigate to `build/viewer`
 
 **GraphQL schemas**
 
-    node node_modules//@graphql-codegen/cli/bin -c src/extensions/rcsb/graphql/codegen.yml
+    node node_modules/@graphql-codegen/cli/cjs/bin -c src/extensions/rcsb/graphql/codegen.yml
 
 ### Other scripts
 **Create chem comp bond table**
@@ -152,7 +152,7 @@ Or
     node lib/commonjs/cli/cif2bcif
 
 E.g.
- 
+
     node lib/commonjs/cli/cif2bcif src.cif out.bcif.gz
     node lib/commonjs/cli/cif2bcif src.bcif.gz out.cif
 
diff --git a/package-lock.json b/package-lock.json
index 1faf8f70905339b665b9056592bf94e14e9bae11..9540458ba5857eb7b8f053d13e70c1cbad4a74f7 100644
Binary files a/package-lock.json and b/package-lock.json differ
diff --git a/package.json b/package.json
index 4227ac03de3f418b8bed97c2fd273d48851bba33..833b3ef38bcceb6ceba49e4b1e5f6dd31ac352d3 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "molstar",
-  "version": "3.13.0",
+  "version": "3.16.0",
   "description": "A comprehensive macromolecular library.",
   "homepage": "https://github.com/molstar/molstar#readme",
   "repository": {
@@ -20,7 +20,7 @@
     "rebuild": "npm run clean && npm run build",
     "build-viewer": "npm run build-tsc && npm run build-extra && npm run build-webpack-viewer",
     "build-tsc": "concurrently \"tsc --incremental\" \"tsc --build tsconfig.commonjs.json --incremental\"",
-    "build-extra": "cpx \"src/**/*.{scss,html,ico}\" lib/",
+    "build-extra": "cpx \"src/**/*.{scss,html,ico,jpg}\" lib/",
     "build-webpack": "webpack --mode production --config ./webpack.config.production.js",
     "build-webpack-viewer": "webpack --mode production --config ./webpack.config.viewer.js",
     "watch": "concurrently -c \"green,green,gray,gray\" --names \"tsc,srv,ext,wpc\" --kill-others \"npm:watch-tsc\" \"npm:watch-servers\" \"npm:watch-extra\" \"npm:watch-webpack\"",
@@ -28,7 +28,7 @@
     "watch-viewer-debug": "concurrently -c \"green,gray,gray\" --names \"tsc,ext,wpc\" --kill-others \"npm:watch-tsc\" \"npm:watch-extra\" \"npm:watch-webpack-viewer-debug\"",
     "watch-tsc": "tsc --watch --incremental",
     "watch-servers": "tsc --build tsconfig.commonjs.json --watch --incremental",
-    "watch-extra": "cpx \"src/**/*.{scss,html,ico}\" lib/ --watch",
+    "watch-extra": "cpx \"src/**/*.{scss,html,ico,jpg}\" lib/ --watch",
     "watch-webpack": "webpack -w --mode development --stats minimal",
     "watch-webpack-viewer": "webpack -w --mode development --stats minimal --config ./webpack.config.viewer.js",
     "watch-webpack-viewer-debug": "webpack -w --mode development --stats minimal --config ./webpack.config.viewer.debug.js",
@@ -75,7 +75,9 @@
       "node_modules",
       "lib"
     ],
-    "testURL": "http://localhost/",
+    "testEnvironmentOptions": {
+      "url": "http://localhost/"
+    },
     "testRegex": "\\.spec\\.ts$"
   },
   "author": "Mol* Contributors",
@@ -88,34 +90,35 @@
     "Michal Malý <michal.maly@ibt.cas.cz>",
     "Jiří Černý <jiri.cerny@ibt.cas.cz>",
     "Panagiotis Tourlas <panagiot_tourlov@hotmail.com>",
+    "Adam Midlik <midlik@gmail.com>",
     "Koya Sakuma <koya.sakuma.work@gmail.com>"
   ],
   "license": "MIT",
   "devDependencies": {
-    "@graphql-codegen/add": "^3.2.0",
-    "@graphql-codegen/cli": "^2.9.1",
-    "@graphql-codegen/time": "^3.2.0",
-    "@graphql-codegen/typescript": "^2.7.2",
-    "@graphql-codegen/typescript-graphql-files-modules": "^2.2.0",
-    "@graphql-codegen/typescript-graphql-request": "^4.5.2",
-    "@graphql-codegen/typescript-operations": "^2.5.2",
+    "@graphql-codegen/add": "^3.2.1",
+    "@graphql-codegen/cli": "^2.11.6",
+    "@graphql-codegen/time": "^3.2.1",
+    "@graphql-codegen/typescript": "^2.7.3",
+    "@graphql-codegen/typescript-graphql-files-modules": "^2.2.1",
+    "@graphql-codegen/typescript-graphql-request": "^4.5.3",
+    "@graphql-codegen/typescript-operations": "^2.5.3",
     "@types/cors": "^2.8.12",
     "@types/gl": "^4.1.1",
-    "@types/jest": "^28.1.6",
-    "@types/react": "^18.0.15",
+    "@types/jest": "^28.1.7",
+    "@types/react": "^18.0.17",
     "@types/react-dom": "^18.0.6",
-    "@typescript-eslint/eslint-plugin": "^5.30.7",
-    "@typescript-eslint/parser": "^5.30.7",
+    "@typescript-eslint/eslint-plugin": "^5.33.1",
+    "@typescript-eslint/parser": "^5.33.1",
     "benchmark": "^2.1.4",
     "concurrently": "^7.3.0",
     "cpx2": "^4.2.0",
     "crypto-browserify": "^3.12.0",
     "css-loader": "^6.7.1",
-    "eslint": "^8.20.0",
+    "eslint": "^8.22.0",
     "extra-watch-webpack-plugin": "^1.0.3",
     "file-loader": "^6.2.0",
     "fs-extra": "^10.1.0",
-    "graphql": "^16.5.0",
+    "graphql": "^16.6.0",
     "http-server": "^14.1.1",
     "jest": "^28.1.3",
     "mini-css-extract-plugin": "^2.6.1",
@@ -123,14 +126,14 @@
     "raw-loader": "^4.0.2",
     "react": "^18.2.0",
     "react-dom": "^18.2.0",
-    "sass": "^1.54.0",
+    "sass": "^1.54.5",
     "sass-loader": "^13.0.2",
-    "simple-git": "^3.10.0",
+    "simple-git": "^3.12.0",
     "stream-browserify": "^3.0.0",
     "style-loader": "^3.3.1",
-    "ts-jest": "^28.0.7",
+    "ts-jest": "^28.0.8",
     "typescript": "^4.7.4",
-    "webpack": "^5.73.0",
+    "webpack": "^5.74.0",
     "webpack-cli": "^4.10.0"
   },
   "dependencies": {
@@ -138,7 +141,7 @@
     "@types/benchmark": "^2.1.1",
     "@types/compression": "1.7.2",
     "@types/express": "^4.17.13",
-    "@types/node": "^16.11.45",
+    "@types/node": "^16.11.51",
     "@types/node-fetch": "^2.6.2",
     "@types/swagger-ui-dist": "3.30.1",
     "argparse": "^2.0.1",
@@ -151,7 +154,7 @@
     "immutable": "^4.1.0",
     "node-fetch": "^2.6.7",
     "rxjs": "^7.5.6",
-    "swagger-ui-dist": "^4.13.0",
+    "swagger-ui-dist": "^4.14.0",
     "tslib": "^2.4.0",
     "util.promisify": "^1.1.1",
     "xhr2": "^0.2.1"
diff --git a/src/apps/docking-viewer/index.ts b/src/apps/docking-viewer/index.ts
index 8242e62030159354074c3e99d9e3f95bea9b57a9..a1fd579695a748004fc9e7559f294581fee2d2aa 100644
--- a/src/apps/docking-viewer/index.ts
+++ b/src/apps/docking-viewer/index.ts
@@ -166,7 +166,7 @@ class Viewer {
             structures.push({ ref: structureProperties?.ref || structure.ref });
         }
 
-        // remove current structuresfrom hierarchy as they will be merged
+        // remove current structures from hierarchy as they will be merged
         // TODO only works with using loadStructuresFromUrlsAndMerge once
         //      need some more API metho to work with the hierarchy
         this.plugin.managers.structure.hierarchy.updateCurrent(this.plugin.managers.structure.hierarchy.current.structures, 'remove');
diff --git a/src/apps/docking-viewer/viewport.tsx b/src/apps/docking-viewer/viewport.tsx
index 78e8412b5ef4dc70f035bf8a3bb03c2f6a379a4e..67976ac1ef065cd73fc79f58c8e1e57bb263d487 100644
--- a/src/apps/docking-viewer/viewport.tsx
+++ b/src/apps/docking-viewer/viewport.tsx
@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2020-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
  */
@@ -202,14 +202,14 @@ const InteractionsPreset = StructureRepresentationPresetProvider({
         const components = {
             ligand: await presetStaticComponent(plugin, structureCell, 'ligand'),
             surroundings: await plugin.builders.structure.tryCreateComponentFromSelection(structureCell, ligandSurroundings, `surroundings`),
-            interactions: await plugin.builders.structure.tryCreateComponentFromSelection(structureCell, ligandPlusSurroundings, `interactions`)
+            interactions: await presetStaticComponent(plugin, structureCell, 'ligand'),
         };
 
         const { update, builder, typeParams } = StructureRepresentationPresetProvider.reprBuilder(plugin, params);
         const representations = {
             ligand: builder.buildRepresentation(update, components.ligand, { type: 'ball-and-stick', typeParams: { ...typeParams, material: CustomMaterial, sizeFactor: 0.3 }, color: 'element-symbol', colorParams: { carbonColor: { name: 'element-symbol', params: {} } } }, { tag: 'ligand' }),
             ballAndStick: builder.buildRepresentation(update, components.surroundings, { type: 'ball-and-stick', typeParams: { ...typeParams, material: CustomMaterial, sizeFactor: 0.1, sizeAspectRatio: 1 }, color: 'element-symbol', colorParams: { carbonColor: { name: 'element-symbol', params: {} } } }, { tag: 'ball-and-stick' }),
-            interactions: builder.buildRepresentation(update, components.interactions, { type: InteractionsRepresentationProvider, typeParams: { ...typeParams, material: CustomMaterial }, color: InteractionTypeColorThemeProvider }, { tag: 'interactions' }),
+            interactions: builder.buildRepresentation(update, components.interactions, { type: InteractionsRepresentationProvider, typeParams: { ...typeParams, material: CustomMaterial, includeParent: true, parentDisplay: 'between' }, color: InteractionTypeColorThemeProvider }, { tag: 'interactions' }),
             label: builder.buildRepresentation(update, components.surroundings, { type: 'label', typeParams: { ...typeParams, material: CustomMaterial, background: false, borderWidth: 0.1 }, color: 'uniform', colorParams: { value: Color(0x000000) } }, { tag: 'label' }),
         };
 
diff --git a/src/apps/viewer/app.ts b/src/apps/viewer/app.ts
index 4190333879470c5f9d664805a9899a5003998ac6..b5d18f401da78d1cfd6c77f75364f7987992d65f 100644
--- a/src/apps/viewer/app.ts
+++ b/src/apps/viewer/app.ts
@@ -46,6 +46,7 @@ import { Color } from '../../mol-util/color';
 import '../../mol-util/polyfill';
 import { ObjectKeys } from '../../mol-util/type-helpers';
 import { SaccharideCompIdMapType } from '../../mol-model/structure/structure/carbohydrates/constants';
+import { Backgrounds } from '../../extensions/backgrounds';
 
 export { PLUGIN_VERSION as version } from '../../mol-plugin/version';
 export { setDebugMode, setProductionMode, setTimingMode } from '../../mol-util/debug';
@@ -55,6 +56,7 @@ const CustomFormats = [
 ];
 
 const Extensions = {
+    'backgrounds': PluginSpec.Behavior(Backgrounds),
     'cellpack': PluginSpec.Behavior(CellPack),
     'dnatco-confal-pyramids': PluginSpec.Behavior(DnatcoConfalPyramids),
     'pdbe-structure-quality-report': PluginSpec.Behavior(PDBeStructureQualityReport),
@@ -197,7 +199,7 @@ export class Viewer {
         return PluginCommands.State.Snapshots.OpenUrl(this.plugin, { url, type });
     }
 
-    loadStructureFromUrl(url: string, format: BuiltInTrajectoryFormat = 'mmcif', isBinary = false, options?: LoadStructureOptions) {
+    loadStructureFromUrl(url: string, format: BuiltInTrajectoryFormat = 'mmcif', isBinary = false, options?: LoadStructureOptions & { label?: string }) {
         const params = DownloadStructure.createDefaultParams(this.plugin.state.data.root.obj!, this.plugin);
         return this.plugin.runTask(this.plugin.state.data.applyAction(DownloadStructure, {
             source: {
@@ -206,6 +208,7 @@ export class Viewer {
                     url: Asset.Url(url),
                     format: format as any,
                     isBinary,
+                    label: options?.label,
                     options: { ...params.source.params.options, representationParams: options?.representationParams as any },
                 }
             }
diff --git a/src/apps/viewer/embedded.html b/src/apps/viewer/embedded.html
index 8533dc365cab388d7d2dae84f216ff55f0840c42..a309a84bdec9dec2d8e9d13488e5f38a8cd5fb0e 100644
--- a/src/apps/viewer/embedded.html
+++ b/src/apps/viewer/embedded.html
@@ -38,6 +38,15 @@
                 viewer.loadPdb('7bv2');
                 viewer.loadEmdb('EMD-30210', { detail: 6 });
                 // viewer.loadAllModelsOrAssemblyFromUrl('https://cs.litemol.org/5ire/full', 'mmcif', false, { representationParams: { theme: { globalName: 'operator-name' } } })
+                // viewer.loadStructureFromUrl('my url', 'pdb', false, {
+                //     representationParams: {
+                //         theme: {
+                //             globalName: 'uniform',
+                //             globalColorParams: { value: 0xff0000 }
+                //         }
+                //     },
+                //     label: 'my structure'
+                // });
             });
         </script>
     </body>
diff --git a/src/extensions/backgrounds/images/cells.jpg b/src/extensions/backgrounds/images/cells.jpg
new file mode 100644
index 0000000000000000000000000000000000000000..3502c798c5d012acd67640ca8cbbb2341014c71f
Binary files /dev/null and b/src/extensions/backgrounds/images/cells.jpg differ
diff --git a/src/extensions/backgrounds/index.ts b/src/extensions/backgrounds/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..c87c8899f367a38bf377f6d266586faf0fe32ae0
--- /dev/null
+++ b/src/extensions/backgrounds/index.ts
@@ -0,0 +1,90 @@
+/**
+ * Copyright (c) 2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Alexander Rose <alexander.rose@weirdbyte.de>
+ */
+
+import { PluginBehavior } from '../../mol-plugin/behavior/behavior';
+import { PluginConfig } from '../../mol-plugin/config';
+import { Color } from '../../mol-util/color/color';
+
+// from https://visualsonline.cancer.gov/details.cfm?imageid=2304, public domain
+import image_cells from './images/cells.jpg';
+
+// created with http://alexcpeterson.com/spacescape/
+import face_nebula_nx from './skyboxes/nebula/nebula_left2.jpg';
+import face_nebula_ny from './skyboxes/nebula/nebula_bottom4.jpg';
+import face_nebula_nz from './skyboxes/nebula/nebula_back6.jpg';
+import face_nebula_px from './skyboxes/nebula/nebula_right1.jpg';
+import face_nebula_py from './skyboxes/nebula/nebula_top3.jpg';
+import face_nebula_pz from './skyboxes/nebula/nebula_front5.jpg';
+
+export const Backgrounds = PluginBehavior.create<{ }>({
+    name: 'extension-backgrounds',
+    category: 'misc',
+    display: {
+        name: 'Backgrounds'
+    },
+    ctor: class extends PluginBehavior.Handler<{ }> {
+        register(): void {
+            this.ctx.config.set(PluginConfig.Background.Styles, [
+                [{
+                    variant: {
+                        name: 'radialGradient',
+                        params: {
+                            centerColor: Color(0xFFFFFF),
+                            edgeColor: Color(0x808080),
+                            ratio: 0.2,
+                            coverage: 'viewport',
+                        }
+                    }
+                }, 'Light Radial Gradient'],
+                [{
+                    variant: {
+                        name: 'image',
+                        params: {
+                            source: {
+                                name: 'url',
+                                params: image_cells
+                            },
+                            lightness: 0,
+                            saturation: 0,
+                            opacity: 1,
+                            coverage: 'viewport',
+                        }
+                    }
+                }, 'Normal Cells Image'],
+                [{
+                    variant: {
+                        name: 'skybox',
+                        params: {
+                            faces: {
+                                name: 'urls',
+                                params: {
+                                    nx: face_nebula_nx,
+                                    ny: face_nebula_ny,
+                                    nz: face_nebula_nz,
+                                    px: face_nebula_px,
+                                    py: face_nebula_py,
+                                    pz: face_nebula_pz,
+                                }
+                            },
+                            lightness: 0,
+                            saturation: 0,
+                            opacity: 1,
+                        }
+                    }
+                }, 'Purple Nebula Skybox'],
+            ]);
+        }
+
+        update() {
+            return false;
+        }
+
+        unregister() {
+            this.ctx.config.set(PluginConfig.Background.Styles, []);
+        }
+    },
+    params: () => ({ })
+});
diff --git a/src/extensions/backgrounds/skyboxes/nebula/nebula_back6.jpg b/src/extensions/backgrounds/skyboxes/nebula/nebula_back6.jpg
new file mode 100644
index 0000000000000000000000000000000000000000..4e2f0fd8d977272a4bc2af9cf982fe866eb52186
Binary files /dev/null and b/src/extensions/backgrounds/skyboxes/nebula/nebula_back6.jpg differ
diff --git a/src/extensions/backgrounds/skyboxes/nebula/nebula_bottom4.jpg b/src/extensions/backgrounds/skyboxes/nebula/nebula_bottom4.jpg
new file mode 100644
index 0000000000000000000000000000000000000000..2be6e805e2ea0103230cf51637218e115cf0a76d
Binary files /dev/null and b/src/extensions/backgrounds/skyboxes/nebula/nebula_bottom4.jpg differ
diff --git a/src/extensions/backgrounds/skyboxes/nebula/nebula_front5.jpg b/src/extensions/backgrounds/skyboxes/nebula/nebula_front5.jpg
new file mode 100644
index 0000000000000000000000000000000000000000..e9c0674db6f28f3d0fa02421e473517790d51f2d
Binary files /dev/null and b/src/extensions/backgrounds/skyboxes/nebula/nebula_front5.jpg differ
diff --git a/src/extensions/backgrounds/skyboxes/nebula/nebula_left2.jpg b/src/extensions/backgrounds/skyboxes/nebula/nebula_left2.jpg
new file mode 100644
index 0000000000000000000000000000000000000000..810037b66dc4601b24b1b1b9adf514f626247b6c
Binary files /dev/null and b/src/extensions/backgrounds/skyboxes/nebula/nebula_left2.jpg differ
diff --git a/src/extensions/backgrounds/skyboxes/nebula/nebula_right1.jpg b/src/extensions/backgrounds/skyboxes/nebula/nebula_right1.jpg
new file mode 100644
index 0000000000000000000000000000000000000000..059d46bf8e7d8d18ce12259679f0c53ad4a5dc27
Binary files /dev/null and b/src/extensions/backgrounds/skyboxes/nebula/nebula_right1.jpg differ
diff --git a/src/extensions/backgrounds/skyboxes/nebula/nebula_top3.jpg b/src/extensions/backgrounds/skyboxes/nebula/nebula_top3.jpg
new file mode 100644
index 0000000000000000000000000000000000000000..831b81964a253fbbd08b0ba3aad74100ce55fad1
Binary files /dev/null and b/src/extensions/backgrounds/skyboxes/nebula/nebula_top3.jpg differ
diff --git a/src/extensions/backgrounds/typings.d.ts b/src/extensions/backgrounds/typings.d.ts
new file mode 100644
index 0000000000000000000000000000000000000000..83e4393576a0b35df23ec0583a74e20387a0b97e
--- /dev/null
+++ b/src/extensions/backgrounds/typings.d.ts
@@ -0,0 +1,10 @@
+/**
+ * Copyright (c) 2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Alexander Rose <alexander.rose@weirdbyte.de>
+ */
+
+declare module '*.jpg' {
+    const value: string;
+    export = value;
+}
diff --git a/src/extensions/dnatco/confal-pyramids/property.ts b/src/extensions/dnatco/confal-pyramids/property.ts
index dc141990ba2db5304821a795ac5ceaa494df05f8..5ca7bada8ce41ae7c0ceb7c51291a891ce06ab8d 100644
--- a/src/extensions/dnatco/confal-pyramids/property.ts
+++ b/src/extensions/dnatco/confal-pyramids/property.ts
@@ -16,7 +16,7 @@ import { PropertyWrapper } from '../../../mol-model-props/common/wrapper';
 import { ParamDefinition as PD } from '../../../mol-util/param-definition';
 import { MmcifFormat } from '../../../mol-model-formats/structure/mmcif';
 
-export type ConfalPyramids = PropertyWrapper<CPT.StepsData | undefined>;
+export type ConfalPyramids = PropertyWrapper<CPT.Steps | undefined>;
 
 export namespace ConfalPyramids {
     export const Schema = {
@@ -105,13 +105,13 @@ export const ConfalPyramidsProvider: CustomModelProperty.Provider<ConfalPyramids
 
 type StepsSummaryTable = Table<typeof ConfalPyramids.Schema.ndb_struct_ntc_step_summary>;
 
-function createPyramidsFromCif(model: Model,
+function createPyramidsFromCif(
+    model: Model,
     cifSteps: Table<typeof ConfalPyramids.Schema.ndb_struct_ntc_step>,
-    stepsSummary: StepsSummaryTable): CPT.StepsData {
+    stepsSummary: StepsSummaryTable
+): CPT.Steps {
     const steps = new Array<CPT.Step>();
-    const names = new Map<string, number>();
-    const halfPyramids = new Array<CPT.HalfPyramid>();
-    let hasMultipleModels = false;
+    const mapping = new Array<CPT.MappedChains>();
 
     const {
         id, PDB_model_number, name,
@@ -123,21 +123,24 @@ function createPyramidsFromCif(model: Model,
     if (_rowCount !== stepsSummary._rowCount) throw new Error('Inconsistent mmCIF data');
 
     for (let i = 0; i < _rowCount; i++) {
-        const model_num = PDB_model_number.value(i);
-        if (model_num !== model.modelNum)
-            hasMultipleModels = true;
-
         const {
             NtC,
             confal_score,
             rmsd
         } = getSummaryData(id.value(i), i, stepsSummary);
+        const modelNum = PDB_model_number.value(i);
+        const chainId = auth_asym_id_1.value(i);
+        const seqId = auth_seq_id_1.value(i);
+        const modelIdx = modelNum - 1;
+
+        if (mapping.length <= modelIdx || !mapping[modelIdx])
+            mapping[modelIdx] = new Map<string, CPT.MappedResidues>();
 
         const step = {
-            PDB_model_number: model_num,
+            PDB_model_number: modelNum,
             name: name.value(i),
-            auth_asym_id_1: auth_asym_id_1.value(i),
-            auth_seq_id_1: auth_seq_id_1.value(i),
+            auth_asym_id_1: chainId,
+            auth_seq_id_1: seqId,
             label_comp_id_1: label_comp_id_1.value(i),
             label_alt_id_1: label_alt_id_1.value(i),
             PDB_ins_code_1: PDB_ins_code_1.value(i),
@@ -152,13 +155,18 @@ function createPyramidsFromCif(model: Model,
         };
 
         steps.push(step);
-        names.set(step.name, steps.length - 1);
 
-        halfPyramids.push({ step, isLower: false });
-        halfPyramids.push({ step, isLower: true });
+        const mappedChains = mapping[modelIdx];
+        const residuesOnChain = mappedChains.get(chainId) ?? new Map<number, number[]>();
+        const stepsForResidue = residuesOnChain.get(seqId) ?? [];
+        stepsForResidue.push(steps.length - 1);
+
+        residuesOnChain.set(seqId, stepsForResidue);
+        mappedChains.set(chainId, residuesOnChain);
+        mapping[modelIdx] = mappedChains;
     }
 
-    return { steps, names, halfPyramids, hasMultipleModels };
+    return { steps, mapping };
 }
 
 function getSummaryData(id: number, i: number, stepsSummary: StepsSummaryTable) {
diff --git a/src/extensions/dnatco/confal-pyramids/representation.ts b/src/extensions/dnatco/confal-pyramids/representation.ts
index d8194e8b4b37b8fbbb99817feed798cb31053ca3..1c7d4f95b1e05aaa06298becd8a4d7f89ecba911 100644
--- a/src/extensions/dnatco/confal-pyramids/representation.ts
+++ b/src/extensions/dnatco/confal-pyramids/representation.ts
@@ -6,7 +6,7 @@
  */
 
 import { ConfalPyramids, ConfalPyramidsProvider } from './property';
-import { ConfalPyramidsUtil } from './util';
+import { ConfalPyramidsIterator } from './util';
 import { ConfalPyramidsTypes as CPT } from './types';
 import { Interval } from '../../../mol-data/int';
 import { Mesh } from '../../../mol-geo/geometry/mesh/mesh';
@@ -32,6 +32,12 @@ const t = Mat4.identity();
 const w = Vec3.zero();
 const mp = Vec3.zero();
 
+const posO3 = Vec3();
+const posP = Vec3();
+const posOP1 = Vec3();
+const posOP2 = Vec3();
+const posO5 = Vec3();
+
 function calcMidpoint(mp: Vec3, v: Vec3, w: Vec3) {
     Vec3.sub(mp, v, w);
     Vec3.scale(mp, mp, 0.5);
@@ -53,64 +59,76 @@ function createConfalPyramidsIterator(structureGroup: StructureGroup): LocationI
     const { structure, group } = structureGroup;
     const instanceCount = group.units.length;
 
-    const prop = ConfalPyramidsProvider.get(structure.model).value;
-    if (prop === undefined || prop.data === undefined) {
-        return LocationIterator(0, 1, 1, () => NullLocation);
-    }
+    const data = ConfalPyramidsProvider.get(structure.model)?.value?.data;
+    if (!data) return LocationIterator(0, 1, 1, () => NullLocation);
 
-    const { halfPyramids } = prop.data;
+    const halfPyramidsCount = data.steps.length * 2;
 
     const getLocation = (groupIndex: number, instanceIndex: number) => {
-        if (halfPyramids.length <= groupIndex) return NullLocation;
-        return CPT.Location(halfPyramids[groupIndex]);
+        if (halfPyramidsCount <= groupIndex) return NullLocation;
+        const idx = Math.floor(groupIndex / 2); // Map groupIndex to a step, see createConfalPyramidsMesh() for full explanation
+        return CPT.Location(data.steps[idx], groupIndex % 2 === 1);
     };
-    return LocationIterator(halfPyramids.length, instanceCount, 1, getLocation);
+    return LocationIterator(halfPyramidsCount, instanceCount, 1, getLocation);
 }
 
 function createConfalPyramidsMesh(ctx: VisualContext, unit: Unit, structure: Structure, theme: Theme, props: PD.Values<ConfalPyramidsMeshParams>, mesh?: Mesh) {
     if (!Unit.isAtomic(unit)) return Mesh.createEmpty(mesh);
 
-    const prop = ConfalPyramidsProvider.get(structure.model).value;
-    if (prop === undefined || prop.data === undefined) return Mesh.createEmpty(mesh);
+    const data = ConfalPyramidsProvider.get(structure.model)?.value?.data;
+    if (!data) return Mesh.createEmpty(mesh);
 
-    const { steps } = prop.data;
+    const { steps, mapping } = data;
     if (steps.length === 0) return Mesh.createEmpty(mesh);
-
-    const mb = MeshBuilder.createState(512, 512, mesh);
-
-    const handler = (step: CPT.Step, first: ConfalPyramidsUtil.FirstResidueAtoms, second: ConfalPyramidsUtil.SecondResidueAtoms, firsLocIndex: number, secondLocIndex: number) => {
-        if (firsLocIndex === -1 || secondLocIndex === -1)
-            throw new Error('Invalid location index');
-
-        const scale = (step.confal_score - 20.0) / 100.0;
-        const O3 = first.O3.pos;
-        const OP1 = second.OP1.pos; const OP2 = second.OP2.pos; const O5 = second.O5.pos; const P = second.P.pos;
-
-        shiftVertex(O3, P, scale);
-        shiftVertex(OP1, P, scale);
-        shiftVertex(OP2, P, scale);
-        shiftVertex(O5, P, scale);
-        calcMidpoint(mp, O3, O5);
-
-        mb.currentGroup = firsLocIndex;
-        let pb = PrimitiveBuilder(3);
-        /* Upper part (for first residue in step) */
-        pb.add(O3, OP1, OP2);
-        pb.add(O3, mp, OP1);
-        pb.add(O3, OP2, mp);
-        MeshBuilder.addPrimitive(mb, t, pb.getPrimitive());
-
-        /* Lower part (for second residue in step */
-        mb.currentGroup = secondLocIndex;
-        pb = PrimitiveBuilder(3);
-        pb.add(mp, O5, OP1);
-        pb.add(mp, OP2, O5);
-        pb.add(O5, OP2, OP1);
-        MeshBuilder.addPrimitive(mb, t, pb.getPrimitive());
-    };
-
-    const walker = new ConfalPyramidsUtil.UnitWalker(structure, unit, handler);
-    walker.walk();
+    const vertexCount = (6 * steps.length) / mapping.length;
+
+    const mb = MeshBuilder.createState(vertexCount, vertexCount / 10, mesh);
+
+    const it = new ConfalPyramidsIterator(structure, unit);
+    while (it.hasNext) {
+        const allPoints = it.move();
+
+        for (const points of allPoints) {
+            const { O3, P, OP1, OP2, O5, confalScore } = points;
+            const scale = (confalScore - 20.0) / 100.0;
+            // Steps can be drawn in a different order than they are stored.
+            // To make sure that we can get from the drawn pyramid back to the step in represents,
+            // we need to use an appropriate groupId. The stepIdx passed from the iterator
+            // is an index into the array of all steps in the structure.
+            // Since a step is drawn as two "half-pyramids" we need two ids to map to a single step.
+            // To do that, we just multiply the index by 2. idx*2 marks the "upper" half-pyramid,
+            // (idx*2)+1 the "lower" half-pyramid.
+            const groupIdx = points.stepIdx * 2;
+
+            unit.conformation.invariantPosition(O3, posO3);
+            unit.conformation.invariantPosition(P, posP);
+            unit.conformation.invariantPosition(OP1, posOP1);
+            unit.conformation.invariantPosition(OP2, posOP2);
+            unit.conformation.invariantPosition(O5, posO5);
+
+            shiftVertex(posO3, posP, scale);
+            shiftVertex(posOP1, posP, scale);
+            shiftVertex(posOP2, posP, scale);
+            shiftVertex(posO5, posP, scale);
+            calcMidpoint(mp, posO3, posO5);
+
+            mb.currentGroup = groupIdx;
+            let pb = PrimitiveBuilder(3);
+            /* Upper part (for first residue in step) */
+            pb.add(posO3, posOP1, posOP2);
+            pb.add(posO3, mp, posOP1);
+            pb.add(posO3, posOP2, mp);
+            MeshBuilder.addPrimitive(mb, t, pb.getPrimitive());
+
+            /* Lower part (for second residue in step) */
+            mb.currentGroup = groupIdx + 1;
+            pb = PrimitiveBuilder(3);
+            pb.add(mp, posO5, posOP1);
+            pb.add(mp, posOP2, posO5);
+            pb.add(posO5, posOP2, posOP1);
+            MeshBuilder.addPrimitive(mb, t, pb.getPrimitive());
+        }
+    }
 
     return MeshBuilder.getMesh(mb);
 }
@@ -124,15 +142,17 @@ function getConfalPyramidLoci(pickingId: PickingId, structureGroup: StructureGro
     const unit = structureGroup.group.units[instanceId];
     if (!Unit.isAtomic(unit)) return EmptyLoci;
 
-    const prop = ConfalPyramidsProvider.get(structure.model).value;
-    if (prop === undefined || prop.data === undefined) return EmptyLoci;
+    const data = ConfalPyramidsProvider.get(structure.model)?.value?.data;
+    if (!data) return EmptyLoci;
+
+    const halfPyramidsCount = data.steps.length * 2;
 
-    const { halfPyramids } = prop.data;
+    if (halfPyramidsCount <= groupId) return EmptyLoci;
 
-    if (halfPyramids.length <= groupId) return EmptyLoci;
-    const hp = halfPyramids[groupId];
+    const idx = Math.floor(groupId / 2); // Map groupIndex to a step, see createConfalPyramidsMesh() for full explanation
+    const step = data.steps[idx];
 
-    return CPT.Loci(hp, [{}]);
+    return CPT.Loci({ step, isLower: groupId % 2 === 1 }, [{}]);
 }
 
 function eachConfalPyramid(loci: Loci, structureGroup: StructureGroup, apply: (interval: Interval) => boolean) {
diff --git a/src/extensions/dnatco/confal-pyramids/types.ts b/src/extensions/dnatco/confal-pyramids/types.ts
index 0e59626927f632392491125ad3f6452bf93aa809..f2b898822333d901e9d99789e1a93fd1ef4f4cf2 100644
--- a/src/extensions/dnatco/confal-pyramids/types.ts
+++ b/src/extensions/dnatco/confal-pyramids/types.ts
@@ -30,11 +30,12 @@ export namespace ConfalPyramidsTypes {
         rmsd: number,
     }
 
-    export interface StepsData {
+    export type MappedChains = Map<string, MappedResidues>;
+    export type MappedResidues = Map<number, number[]>;
+
+    export interface Steps {
         steps: Array<Step>,
-        names: Map<string, number>,
-        halfPyramids: Array<HalfPyramid>,
-        hasMultipleModels: boolean
+        mapping: MappedChains[],
     }
 
     export interface HalfPyramid {
@@ -44,8 +45,8 @@ export namespace ConfalPyramidsTypes {
 
     export interface Location extends DataLocation<HalfPyramid, {}> {}
 
-    export function Location(halfPyramid: HalfPyramid) {
-        return DataLocation(DataTag, halfPyramid, {});
+    export function Location(step: Step, isLower: boolean) {
+        return DataLocation(DataTag, { step, isLower }, {});
     }
 
     export function isLocation(x: any): x is Location {
diff --git a/src/extensions/dnatco/confal-pyramids/util.ts b/src/extensions/dnatco/confal-pyramids/util.ts
index 698a34e6d83fe295da1ce6454d788905be90e864..af4e9793f259d202a52ccb0b896c7156bea87ae4 100644
--- a/src/extensions/dnatco/confal-pyramids/util.ts
+++ b/src/extensions/dnatco/confal-pyramids/util.ts
@@ -8,280 +8,120 @@
 import { ConfalPyramidsProvider } from './property';
 import { ConfalPyramidsTypes as CPT } from './types';
 import { Segmentation } from '../../../mol-data/int';
-import { Vec3 } from '../../../mol-math/linear-algebra';
 import { ChainIndex, ElementIndex, ResidueIndex, Structure, StructureElement, StructureProperties, Unit } from '../../../mol-model/structure';
 
-export namespace ConfalPyramidsUtil {
-    type Residue = Segmentation.Segment<ResidueIndex>;
+type Residue = Segmentation.Segment<ResidueIndex>;
 
-    export type AtomInfo = {
-        pos: Vec3,
-        index: ElementIndex,
-        fakeAltId: string,
-    };
+export type Pyramid = {
+    O3: ElementIndex,
+    P: ElementIndex,
+    OP1: ElementIndex,
+    OP2: ElementIndex,
+    O5: ElementIndex,
+    confalScore: number,
+    stepIdx: number,
+};
 
-    export type FirstResidueAtoms = {
-        O3: AtomInfo,
-    };
+const EmptyStepIndices = new Array<number>();
 
-    export type SecondResidueAtoms = {
-        OP1: AtomInfo,
-        OP2: AtomInfo,
-        O5: AtomInfo,
-        P: AtomInfo,
-    };
-
-    type ResidueInfo = {
-        PDB_model_num: number,
-        asym_id: string,
-        auth_asym_id: string,
-        seq_id: number,
-        auth_seq_id: number,
-        comp_id: string,
-        alt_id: string,
-        ins_code: string,
-    };
+function copyResidue(r?: Residue) {
+    return r ? { index: r.index, start: r.start, end: r.end } : void 0;
+}
 
-    export type Handler = (pyramid: CPT.Step, first: FirstResidueAtoms, second: SecondResidueAtoms, firstLocIndex: number, secondLocIndex: number) => void;
+function getAtomIndex(loc: StructureElement.Location, residue: Residue, names: string[], altId: string): ElementIndex {
+    for (let eI = residue.start; eI < residue.end; eI++) {
+        loc.element = loc.unit.elements[eI];
+        const elName = StructureProperties.atom.label_atom_id(loc);
+        const elAltId = StructureProperties.atom.label_alt_id(loc);
 
-    function residueInfoFromLocation(loc: StructureElement.Location): ResidueInfo {
-        return {
-            PDB_model_num: StructureProperties.unit.model_num(loc),
-            asym_id: StructureProperties.chain.label_asym_id(loc),
-            auth_asym_id: StructureProperties.chain.auth_asym_id(loc),
-            seq_id: StructureProperties.residue.label_seq_id(loc),
-            auth_seq_id: StructureProperties.residue.auth_seq_id(loc),
-            comp_id: StructureProperties.atom.label_comp_id(loc),
-            alt_id: StructureProperties.atom.label_alt_id(loc),
-            ins_code: StructureProperties.residue.pdbx_PDB_ins_code(loc)
-        };
+        if (names.includes(elName) && (elAltId === altId || elAltId.length === 0))
+            return loc.element;
     }
 
-    export function hasMultipleModels(unit: Unit.Atomic): boolean {
-        const prop = ConfalPyramidsProvider.get(unit.model).value;
-        if (prop === undefined || prop.data === undefined) throw new Error('No custom properties data');
-        return prop.data.hasMultipleModels;
-    }
+    return -1 as ElementIndex;
+}
 
-    function getPossibleAltIds(residue: Residue, structure: Structure, unit: Unit.Atomic): string[] {
-        const possibleAltIds: string[] = [];
+function getPyramid(loc: StructureElement.Location, one: Residue, two: Residue, altIdOne: string, altIdTwo: string, confalScore: number, stepIdx: number): Pyramid {
+    const O3 = getAtomIndex(loc, one, ['O3\'', 'O3*'], altIdOne);
+    const P = getAtomIndex(loc, two, ['P'], altIdTwo);
+    const OP1 = getAtomIndex(loc, two, ['OP1'], altIdTwo);
+    const OP2 = getAtomIndex(loc, two, ['OP2'], altIdTwo);
+    const O5 = getAtomIndex(loc, two, ['O5\'', 'O5*'], altIdTwo);
 
-        const loc = StructureElement.Location.create(structure, unit, -1 as ElementIndex);
-        for (let rI = residue.start; rI <= residue.end - 1; rI++) {
-            loc.element = unit.elements[rI];
-            const altId = StructureProperties.atom.label_alt_id(loc);
-            if (altId !== '' && !possibleAltIds.includes(altId)) possibleAltIds.push(altId);
-        }
+    return { O3, P, OP1, OP2, O5, confalScore, stepIdx };
+}
 
-        return possibleAltIds;
+export class ConfalPyramidsIterator {
+    private chainIt: Segmentation.SegmentIterator<ChainIndex>;
+    private residueIt: Segmentation.SegmentIterator<ResidueIndex>;
+    private residueOne?: Residue;
+    private residueTwo: Residue;
+    private data?: CPT.Steps;
+    private loc: StructureElement.Location;
+
+    private getStepIndices(r: Residue) {
+        this.loc.element = this.loc.unit.elements[r.start];
+
+        const modelIdx = StructureProperties.unit.model_num(this.loc) - 1;
+        const chainId = StructureProperties.chain.auth_asym_id(this.loc);
+        const seqId = StructureProperties.residue.auth_seq_id(this.loc);
+
+        const chains = this.data!.mapping[modelIdx];
+        if (!chains) return EmptyStepIndices;
+        const residues = chains.get(chainId);
+        if (!residues) return EmptyStepIndices;
+        return residues.get(seqId) ?? EmptyStepIndices;
     }
 
-    class Utility {
-        protected getPyramidByName(name: string): { pyramid: CPT.Step | undefined, index: number } {
-            const index = this.data.names.get(name);
-            if (index === undefined) return { pyramid: undefined, index: -1 };
-
-            return { pyramid: this.data.steps[index], index };
-        }
-
-        protected stepToName(entry_id: string, modelNum: number, locFirst: StructureElement.Location, locSecond: StructureElement.Location, fakeAltId_1: string, fakeAltId_2: string) {
-            const first = residueInfoFromLocation(locFirst);
-            const second = residueInfoFromLocation(locSecond);
-            const model_id = this.hasMultipleModels ? `-m${modelNum}` : '';
-            const alt_id_1 = fakeAltId_1 !== '' ? `.${fakeAltId_1}` : (first.alt_id.length ? `.${first.alt_id}` : '');
-            const alt_id_2 = fakeAltId_2 !== '' ? `.${fakeAltId_2}` : (second.alt_id.length ? `.${second.alt_id}` : '');
-            const ins_code_1 = first.ins_code.length ? `.${first.ins_code}` : '';
-            const ins_code_2 = second.ins_code.length ? `.${second.ins_code}` : '';
-
-            return `${entry_id}${model_id}_${first.auth_asym_id}_${first.comp_id}${alt_id_1}_${first.auth_seq_id}${ins_code_1}_${second.comp_id}${alt_id_2}_${second.auth_seq_id}${ins_code_2}`;
-        }
-
-        constructor(unit: Unit.Atomic) {
-            const prop = ConfalPyramidsProvider.get(unit.model).value;
-            if (prop === undefined || prop.data === undefined) throw new Error('No custom properties data');
-
-            this.data = prop.data;
-            this.hasMultipleModels = hasMultipleModels(unit);
+    private moveStep() {
+        this.residueOne = copyResidue(this.residueTwo);
+        this.residueTwo = copyResidue(this.residueIt.move())!;
 
-            this.entryId = unit.model.entryId.toLowerCase();
-            this.modelNum = unit.model.modelNum;
-        }
-
-        protected readonly data: CPT.StepsData;
-        protected readonly hasMultipleModels: boolean;
-        protected readonly entryId: string;
-        protected readonly modelNum: number;
+        return this.toPyramids(this.residueOne!, this.residueTwo);
     }
 
-    export class UnitWalker extends Utility {
-        private getAtomIndices(names: string[], residue: Residue): ElementIndex[] {
-            const indices: ElementIndex[] = [];
-
-            const loc = StructureElement.Location.create(this.structure, this.unit, -1 as ElementIndex);
-            for (let rI = residue.start; rI <= residue.end - 1; rI++) {
-                loc.element = this.unit.elements[rI];
-                const thisName = StructureProperties.atom.label_atom_id(loc);
-                if (names.includes(thisName)) indices.push(loc.element);
-            }
-
-            if (indices.length === 0) {
-                let namesStr = '';
-                for (const n of names)
-                    namesStr += `${n} `;
+    private toPyramids(one: Residue, two: Residue) {
+        const indices = this.getStepIndices(one);
 
-                throw new Error(`Element [${namesStr}] not found on residue ${residue.index}`);
-            }
-
-            return indices;
+        const points = [];
+        for (const idx of indices) {
+            const step = this.data!.steps[idx];
+            points.push(getPyramid(this.loc, one, two, step.label_alt_id_1, step.label_alt_id_2, step.confal_score, idx));
         }
 
-        private getAtomPositions(indices: ElementIndex[]): Vec3[] {
-            const pos = this.unit.conformation.invariantPosition;
-            const positions: Vec3[] = [];
-
-            for (const eI of indices) {
-                const v = Vec3.zero();
-                pos(eI, v);
-                positions.push(v);
-            }
-
-            return positions;
-        }
-
-        private handleStep(firstAtoms: FirstResidueAtoms[], secondAtoms: SecondResidueAtoms[]) {
-            const modelNum = this.hasMultipleModels ? this.modelNum : -1;
-            let ok = false;
-
-            const firstLoc = StructureElement.Location.create(this.structure, this.unit, -1 as ElementIndex);
-            const secondLoc = StructureElement.Location.create(this.structure, this.unit, -1 as ElementIndex);
-            for (let i = 0; i < firstAtoms.length; i++) {
-                const first = firstAtoms[i];
-                for (let j = 0; j < secondAtoms.length; j++) {
-                    const second = secondAtoms[j];
-                    firstLoc.element = first.O3.index;
-                    secondLoc.element = second.OP1.index;
-
-                    const name = this.stepToName(this.entryId, modelNum, firstLoc, secondLoc, first.O3.fakeAltId, second.OP1.fakeAltId);
-                    const { pyramid, index } = this.getPyramidByName(name);
-                    if (pyramid !== undefined) {
-                        const locIndex = index * 2;
-                        this.handler(pyramid, first, second, locIndex, locIndex + 1);
-                        ok = true;
-                    }
-                }
-            }
-
-            if (!ok) throw new Error('Bogus step');
-        }
-
-        private processFirstResidue(residue: Residue, possibleAltIds: string[]) {
-            const indO3 = this.getAtomIndices(['O3\'', 'O3*'], residue);
-            const posO3 = this.getAtomPositions(indO3);
-
-            const altPos: FirstResidueAtoms[] = [
-                { O3: { pos: posO3[0], index: indO3[0], fakeAltId: '' } }
-            ];
-
-            for (let i = 1; i < indO3.length; i++) {
-                altPos.push({ O3: { pos: posO3[i], index: indO3[i], fakeAltId: '' } });
-            }
-
-            if (altPos.length === 1 && possibleAltIds.length > 1) {
-                /* We have some alternate positions on the residue but O3 does not have any - fake them */
-                altPos[0].O3.fakeAltId = possibleAltIds[0];
-
-                for (let i = 1; i < possibleAltIds.length; i++)
-                    altPos.push({ O3: { pos: posO3[0], index: indO3[0], fakeAltId: possibleAltIds[i] } });
-            }
-
-            return altPos;
-        }
-
-        private processSecondResidue(residue: Residue, possibleAltIds: string[]) {
-            const indOP1 = this.getAtomIndices(['OP1'], residue);
-            const indOP2 = this.getAtomIndices(['OP2'], residue);
-            const indO5 = this.getAtomIndices(['O5\'', 'O5*'], residue);
-            const indP = this.getAtomIndices(['P'], residue);
-
-            const posOP1 = this.getAtomPositions(indOP1);
-            const posOP2 = this.getAtomPositions(indOP2);
-            const posO5 = this.getAtomPositions(indO5);
-            const posP = this.getAtomPositions(indP);
-
-            const infoOP1: AtomInfo[] = [];
-            /* We use OP1 as "pivotal" atom. There is no specific reason
-             * to pick OP1, it is as good a choice as any other atom
-             */
-            if (indOP1.length === 1 && possibleAltIds.length > 1) {
-                /* No altIds on OP1, fake them */
-                for (const altId of possibleAltIds)
-                    infoOP1.push({ pos: posOP1[0], index: indOP1[0], fakeAltId: altId });
-            } else {
-                for (let i = 0; i < indOP1.length; i++)
-                    infoOP1.push({ pos: posOP1[i], index: indOP1[i], fakeAltId: '' });
-            }
-
-            const mkInfo = (i: number, indices: ElementIndex[], positions: Vec3[], altId: string) => {
-                if (i >= indices.length) {
-                    const last = indices.length - 1;
-                    return { pos: positions[last], index: indices[last], fakeAltId: altId };
-                }
-
-                return { pos: positions[i], index: indices[i], fakeAltId: altId };
-            };
-
-            const altPos: SecondResidueAtoms[] = [];
-            for (let i = 0; i < infoOP1.length; i++) {
-                const altId = infoOP1[i].fakeAltId;
-
-                const OP2 = mkInfo(i, indOP2, posOP2, altId);
-                const O5 = mkInfo(i, indO5, posO5, altId);
-                const P = mkInfo(i, indP, posP, altId);
-
-                altPos.push({ OP1: infoOP1[i], OP2, O5, P });
-            }
-
-            return altPos;
-        }
-
-        private step(residue: Residue): { firstAtoms: FirstResidueAtoms[], secondAtoms: SecondResidueAtoms[] } {
-            const firstPossibleAltIds = getPossibleAltIds(residue, this.structure, this.unit);
-            const firstAtoms = this.processFirstResidue(residue, firstPossibleAltIds);
+        return points;
+    }
 
-            residue = this.residueIt.move();
+    constructor(structure: Structure, unit: Unit) {
+        this.chainIt = Segmentation.transientSegments(unit.model.atomicHierarchy.chainAtomSegments, unit.elements);
+        this.residueIt = Segmentation.transientSegments(unit.model.atomicHierarchy.residueAtomSegments, unit.elements);
 
-            const secondPossibleAltIds = getPossibleAltIds(residue, this.structure, this.unit);
-            const secondAtoms = this.processSecondResidue(residue, secondPossibleAltIds);
+        const prop = ConfalPyramidsProvider.get(unit.model).value;
+        this.data = prop?.data;
 
-            return { firstAtoms, secondAtoms };
+        if (this.chainIt.hasNext) {
+            this.residueIt.setSegment(this.chainIt.move());
+            if (this.residueIt.hasNext)
+                this.residueTwo = this.residueIt.move();
         }
 
-        walk() {
-            while (this.chainIt.hasNext) {
-                this.residueIt.setSegment(this.chainIt.move());
-
-                let residue = this.residueIt.move();
-                while (this.residueIt.hasNext) {
-                    try {
-                        const { firstAtoms, secondAtoms } = this.step(residue);
-
-                        this.handleStep(firstAtoms, secondAtoms);
-                    } catch (error) {
-                        /* Skip and move along */
-                        residue = this.residueIt.move();
-                    }
-                }
-            }
-        }
+        this.loc = StructureElement.Location.create(structure, unit, -1 as ElementIndex);
+    }
 
-        constructor(private structure: Structure, private unit: Unit.Atomic, private handler: Handler) {
-            super(unit);
+    get hasNext() {
+        if (!this.data)
+            return false;
+        return this.residueIt.hasNext
+            ? true
+            : this.chainIt.hasNext;
+    }
 
-            this.chainIt = Segmentation.transientSegments(unit.model.atomicHierarchy.chainAtomSegments, unit.elements);
-            this.residueIt = Segmentation.transientSegments(unit.model.atomicHierarchy.residueAtomSegments, unit.elements);
+    move() {
+        if (this.residueIt.hasNext) {
+            return this.moveStep();
+        } else {
+            this.residueIt.setSegment(this.chainIt.move());
+            return this.moveStep();
         }
-
-        private chainIt: Segmentation.SegmentIterator<ChainIndex>;
-        private residueIt: Segmentation.SegmentIterator<ResidueIndex>;
     }
 }
diff --git a/src/extensions/mp4-export/encoder.ts b/src/extensions/mp4-export/encoder.ts
index 5dc12af421808abee78e69e341f7b4d7ae263e4f..38e96599220a377c94704939d9a951be46d1ff17 100644
--- a/src/extensions/mp4-export/encoder.ts
+++ b/src/extensions/mp4-export/encoder.ts
@@ -69,6 +69,7 @@ export async function encodeMp4Animation<A extends PluginStateAnimation>(plugin:
         const dt = durationMs / N;
 
         await ctx.update({ message: 'Rendering...', isIndeterminate: false, current: 0, max: N + 1 });
+        await params.pass.updateBackground();
 
         await plugin.managers.animation.play(params.animation.definition, params.animation.params);
         stoppedAnimation = false;
diff --git a/src/extensions/rcsb/graphql/types.ts b/src/extensions/rcsb/graphql/types.ts
index a113cbbe299abb84df59e65f4ee6d21cf6894ea2..58fc86f4e00d4ef5bf0743dbc2e23756466d3edf 100644
--- a/src/extensions/rcsb/graphql/types.ts
+++ b/src/extensions/rcsb/graphql/types.ts
@@ -4,7 +4,7 @@ export type InputMaybe<T> = Maybe<T>;
 export type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] };
 export type MakeOptional<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]?: Maybe<T[SubKey]> };
 export type MakeMaybe<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]: Maybe<T[SubKey]> };
-// Generated on 2022-06-26T14:02:35-07:00
+// Generated on 2022-08-20T16:36:05-07:00
 
 /** All built-in and custom scalars, mapped to their actual values */
 export type Scalars = {
@@ -13,11 +13,8 @@ export type Scalars = {
   Boolean: boolean;
   Int: number;
   Float: number;
-  /** Built-in scalar representing an instant in time */
   Date: any;
-  /** Built-in scalar for dynamic values */
   ObjectScalar: any;
-  /** Use SPQR's SchemaPrinter to remove this from SDL */
   UNREPRESENTABLE: any;
 };
 
diff --git a/src/mol-canvas3d/canvas3d.ts b/src/mol-canvas3d/canvas3d.ts
index edb4d15f0dd17a5696439ef2421ab9a587d08407..e0c3335651cbb1cd19be7ea1f1417d3fa77a9d0e 100644
--- a/src/mol-canvas3d/canvas3d.ts
+++ b/src/mol-canvas3d/canvas3d.ts
@@ -40,6 +40,9 @@ import { Passes } from './passes/passes';
 import { shallowEqual } from '../mol-util';
 import { MarkingParams } from './passes/marking';
 import { GraphicsRenderVariantsBlended, GraphicsRenderVariantsWboit } from '../mol-gl/webgl/render-item';
+import { degToRad, radToDeg } from '../mol-math/misc';
+import { AssetManager } from '../mol-util/assets';
+import { deepClone } from '../mol-util/object';
 
 export const Canvas3DParams = {
     camera: PD.Group({
@@ -49,6 +52,7 @@ export const Canvas3DParams = {
             on: PD.Group(StereoCameraParams),
             off: PD.Group({})
         }, { cycle: true, hideIf: p => p?.mode !== 'perspective' }),
+        fov: PD.Numeric(45, { min: 10, max: 130, step: 1 }, { label: 'Field of View' }),
         manualReset: PD.Boolean(false, { isHidden: true }),
     }, { pivot: 'mode' }),
     cameraFog: PD.MappedStatic('on', {
@@ -78,6 +82,7 @@ export const Canvas3DParams = {
     }),
 
     cameraResetDurationMs: PD.Numeric(250, { min: 0, max: 1000, step: 1 }, { description: 'The time it takes to reset the camera.' }),
+    sceneRadiusFactor: PD.Numeric(1, { min: 1, max: 10, step: 0.1 }),
     transparentBackground: PD.Boolean(false),
 
     multiSample: PD.Group(MultiSampleParams),
@@ -106,6 +111,7 @@ interface Canvas3DContext {
     readonly attribs: Readonly<Canvas3DContext.Attribs>
     readonly contextLost: BehaviorSubject<now.Timestamp>
     readonly contextRestored: BehaviorSubject<now.Timestamp>
+    readonly assetManager: AssetManager
     dispose: (options?: Partial<{ doNotForceWebGLContextLoss: boolean }>) => void
 }
 
@@ -124,7 +130,7 @@ namespace Canvas3DContext {
     };
     export type Attribs = typeof DefaultAttribs
 
-    export function fromCanvas(canvas: HTMLCanvasElement, attribs: Partial<Attribs> = {}): Canvas3DContext {
+    export function fromCanvas(canvas: HTMLCanvasElement, assetManager: AssetManager, attribs: Partial<Attribs> = {}): Canvas3DContext {
         const a = { ...DefaultAttribs, ...attribs };
         const { antialias, preserveDrawingBuffer, pixelScale, preferWebGl1 } = a;
         const gl = getGLContext(canvas, {
@@ -139,7 +145,7 @@ namespace Canvas3DContext {
 
         const input = InputObserver.fromElement(canvas, { pixelScale, preventGestures: true });
         const webgl = createContext(gl, { pixelScale });
-        const passes = new Passes(webgl, a);
+        const passes = new Passes(webgl, assetManager, a);
 
         if (isDebugMode) {
             const loseContextExt = gl.getExtension('WEBGL_lose_context');
@@ -192,6 +198,7 @@ namespace Canvas3DContext {
             attribs: a,
             contextLost,
             contextRestored: webgl.contextRestored,
+            assetManager,
             dispose: (options?: Partial<{ doNotForceWebGLContextLoss: boolean }>) => {
                 input.dispose();
 
@@ -278,8 +285,8 @@ namespace Canvas3D {
     export interface DragEvent { current: Representation.Loci, buttons: ButtonsType, button: ButtonsType.Flag, modifiers: ModifiersKeys, pageStart: Vec2, pageEnd: Vec2 }
     export interface ClickEvent { current: Representation.Loci, buttons: ButtonsType, button: ButtonsType.Flag, modifiers: ModifiersKeys, page?: Vec2, position?: Vec3 }
 
-    export function create({ webgl, input, passes, attribs }: Canvas3DContext, props: Partial<Canvas3DProps> = {}): Canvas3D {
-        const p: Canvas3DProps = { ...DefaultCanvas3DParams, ...props };
+    export function create({ webgl, input, passes, attribs, assetManager }: Canvas3DContext, props: Partial<Canvas3DProps> = {}): Canvas3D {
+        const p: Canvas3DProps = { ...deepClone(DefaultCanvas3DParams), ...deepClone(props) };
 
         const reprRenderObjects = new Map<Representation.Any, Set<GraphicsRenderObject>>();
         const reprUpdatedSubscriptions = new Map<Representation.Any, Subscription>();
@@ -299,11 +306,16 @@ namespace Canvas3D {
 
         const scene = Scene.create(webgl, passes.draw.wboitEnabled ? GraphicsRenderVariantsWboit : GraphicsRenderVariantsBlended);
 
+        function getSceneRadius() {
+            return scene.boundingSphere.radius * p.sceneRadiusFactor;
+        }
+
         const camera = new Camera({
             position: Vec3.create(0, 0, 100),
             mode: p.camera.mode,
             fog: p.cameraFog.name === 'on' ? p.cameraFog.params.intensity : 0,
-            clipFar: p.cameraClipping.far
+            clipFar: p.cameraClipping.far,
+            fov: degToRad(p.camera.fov),
         }, { x, y, width, height }, { pixelScale: attribs.pixelScale });
         const stereoCamera = new StereoCamera(camera, p.camera.stereo.params);
 
@@ -315,6 +327,10 @@ namespace Canvas3D {
         const interactionHelper = new Canvas3dInteractionHelper(identify, getLoci, input, camera, p.interaction);
         const multiSampleHelper = new MultiSampleHelper(passes.multiSample);
 
+        passes.draw.postprocessing.background.update(camera, p.postprocessing.background, changed => {
+            if (changed) requestDraw();
+        });
+
         let cameraResetRequested = false;
         let nextCameraResetDuration: number | undefined = void 0;
         let nextCameraResetSnapshot: Camera.SnapshotProvider | undefined = void 0;
@@ -523,7 +539,7 @@ namespace Canvas3D {
                 const focus = camera.getFocus(center, radius);
                 const next = typeof nextCameraResetSnapshot === 'function' ? nextCameraResetSnapshot(scene, camera) : nextCameraResetSnapshot;
                 const snapshot = next ? { ...focus, ...next } : focus;
-                camera.setState({ ...snapshot, radiusMax: scene.boundingSphere.radius }, duration);
+                camera.setState({ ...snapshot, radiusMax: getSceneRadius() }, duration);
             }
 
             nextCameraResetDuration = void 0;
@@ -574,7 +590,7 @@ namespace Canvas3D {
             }
             if (oldBoundingSphereVisible.radius === 0) nextCameraResetDuration = 0;
 
-            if (!p.camera.manualReset) camera.setState({ radiusMax: scene.boundingSphere.radius }, 0);
+            if (!p.camera.manualReset) camera.setState({ radiusMax: getSceneRadius() }, 0);
             reprCount.next(reprRenderObjects.size);
             if (isDebugMode) consoleStats();
 
@@ -650,7 +666,7 @@ namespace Canvas3D {
 
         function getProps(): Canvas3DProps {
             const radius = scene.boundingSphere.radius > 0
-                ? 100 - Math.round((camera.transition.target.radius / scene.boundingSphere.radius) * 100)
+                ? 100 - Math.round((camera.transition.target.radius / getSceneRadius()) * 100)
                 : 0;
 
             return {
@@ -658,6 +674,7 @@ namespace Canvas3D {
                     mode: camera.state.mode,
                     helper: { ...helper.camera.props },
                     stereo: { ...p.camera.stereo },
+                    fov: Math.round(radToDeg(camera.state.fov)),
                     manualReset: !!p.camera.manualReset
                 },
                 cameraFog: camera.state.fog > 0
@@ -665,6 +682,7 @@ namespace Canvas3D {
                     : { name: 'off' as const, params: {} },
                 cameraClipping: { far: camera.state.clipFar, radius },
                 cameraResetDurationMs: p.cameraResetDurationMs,
+                sceneRadiusFactor: p.sceneRadiusFactor,
                 transparentBackground: p.transparentBackground,
                 viewport: p.viewport,
 
@@ -767,10 +785,19 @@ namespace Canvas3D {
                     ? produce(getProps(), properties as any)
                     : properties;
 
+                if (props.sceneRadiusFactor !== undefined) {
+                    p.sceneRadiusFactor = props.sceneRadiusFactor;
+                    camera.setState({ radiusMax: getSceneRadius() }, 0);
+                }
+
                 const cameraState: Partial<Camera.Snapshot> = Object.create(null);
                 if (props.camera && props.camera.mode !== undefined && props.camera.mode !== camera.state.mode) {
                     cameraState.mode = props.camera.mode;
                 }
+                const oldFov = Math.round(radToDeg(camera.state.fov));
+                if (props.camera && props.camera.fov !== undefined && props.camera.fov !== oldFov) {
+                    cameraState.fov = degToRad(props.camera.fov);
+                }
                 if (props.cameraFog !== undefined && props.cameraFog.params) {
                     const newFog = props.cameraFog.name === 'on' ? props.cameraFog.params.intensity : 0;
                     if (newFog !== camera.state.fog) cameraState.fog = newFog;
@@ -780,7 +807,7 @@ namespace Canvas3D {
                         cameraState.clipFar = props.cameraClipping.far;
                     }
                     if (props.cameraClipping.radius !== undefined) {
-                        const radius = (scene.boundingSphere.radius / 100) * (100 - props.cameraClipping.radius);
+                        const radius = (getSceneRadius() / 100) * (100 - props.cameraClipping.radius);
                         if (radius > 0 && radius !== cameraState.radius) {
                             // if radius = 0, NaNs happen
                             cameraState.radius = Math.max(radius, 0.01);
@@ -805,6 +832,12 @@ namespace Canvas3D {
                     }
                 }
 
+                if (props.postprocessing?.background) {
+                    Object.assign(p.postprocessing.background, props.postprocessing.background);
+                    passes.draw.postprocessing.background.update(camera, p.postprocessing.background, changed => {
+                        if (changed && !doNotRequestDraw) requestDraw();
+                    });
+                }
                 if (props.postprocessing) Object.assign(p.postprocessing, props.postprocessing);
                 if (props.marking) Object.assign(p.marking, props.marking);
                 if (props.multiSample) Object.assign(p.multiSample, props.multiSample);
@@ -823,7 +856,7 @@ namespace Canvas3D {
                 }
             },
             getImagePass: (props: Partial<ImageProps> = {}) => {
-                return new ImagePass(webgl, renderer, scene, camera, helper, passes.draw.wboitEnabled, props);
+                return new ImagePass(webgl, assetManager, renderer, scene, camera, helper, passes.draw.wboitEnabled, props);
             },
             getRenderObjects(): GraphicsRenderObject[] {
                 const renderObjects: GraphicsRenderObject[] = [];
diff --git a/src/mol-canvas3d/passes/background.ts b/src/mol-canvas3d/passes/background.ts
new file mode 100644
index 0000000000000000000000000000000000000000..d4bfb3e59aa3fb02cfdc4e958b61618dbc500235
--- /dev/null
+++ b/src/mol-canvas3d/passes/background.ts
@@ -0,0 +1,461 @@
+/**
+ * Copyright (c) 2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Alexander Rose <alexander.rose@weirdbyte.de>
+ */
+
+import { QuadPositions, } from '../../mol-gl/compute/util';
+import { ComputeRenderable, createComputeRenderable } from '../../mol-gl/renderable';
+import { AttributeSpec, DefineSpec, TextureSpec, UniformSpec, Values, ValueSpec } from '../../mol-gl/renderable/schema';
+import { ShaderCode } from '../../mol-gl/shader-code';
+import { background_frag } from '../../mol-gl/shader/background.frag';
+import { background_vert } from '../../mol-gl/shader/background.vert';
+import { WebGLContext } from '../../mol-gl/webgl/context';
+import { createComputeRenderItem } from '../../mol-gl/webgl/render-item';
+import { createNullTexture, CubeFaces, Texture } from '../../mol-gl/webgl/texture';
+import { Mat4 } from '../../mol-math/linear-algebra/3d/mat4';
+import { ValueCell } from '../../mol-util/value-cell';
+import { ParamDefinition as PD } from '../../mol-util/param-definition';
+import { isTimingMode } from '../../mol-util/debug';
+import { Camera, ICamera } from '../camera';
+import { Vec3 } from '../../mol-math/linear-algebra/3d/vec3';
+import { Vec2 } from '../../mol-math/linear-algebra/3d/vec2';
+import { Color } from '../../mol-util/color';
+import { Asset, AssetManager } from '../../mol-util/assets';
+import { Vec4 } from '../../mol-math/linear-algebra/3d/vec4';
+
+const SharedParams = {
+    opacity: PD.Numeric(1, { min: 0.0, max: 1.0, step: 0.01 }),
+    saturation: PD.Numeric(0, { min: -1, max: 1, step: 0.01 }),
+    lightness: PD.Numeric(0, { min: -1, max: 1, step: 0.01 }),
+};
+
+const SkyboxParams = {
+    faces: PD.MappedStatic('urls', {
+        urls: PD.Group({
+            nx: PD.Text('', { label: 'Negative X / Left' }),
+            ny: PD.Text('', { label: 'Negative Y / Bottom' }),
+            nz: PD.Text('', { label: 'Negative Z / Back' }),
+            px: PD.Text('', { label: 'Positive X / Right' }),
+            py: PD.Text('', { label: 'Positive Y / Top' }),
+            pz: PD.Text('', { label: 'Positive Z / Front' }),
+        }, { isExpanded: true, label: 'URLs' }),
+        files: PD.Group({
+            nx: PD.File({ label: 'Negative X / Left', accept: 'image/*' }),
+            ny: PD.File({ label: 'Negative Y / Bottom', accept: 'image/*' }),
+            nz: PD.File({ label: 'Negative Z / Back', accept: 'image/*' }),
+            px: PD.File({ label: 'Positive X / Right', accept: 'image/*' }),
+            py: PD.File({ label: 'Positive Y / Top', accept: 'image/*' }),
+            pz: PD.File({ label: 'Positive Z / Front', accept: 'image/*' }),
+        }, { isExpanded: true, label: 'Files' }),
+    }),
+    ...SharedParams,
+};
+type SkyboxProps = PD.Values<typeof SkyboxParams>
+
+const ImageParams = {
+    source: PD.MappedStatic('url', {
+        url: PD.Text(''),
+        file: PD.File({ accept: 'image/*' }),
+    }),
+    ...SharedParams,
+    coverage: PD.Select('viewport', PD.arrayToOptions(['viewport', 'canvas'])),
+};
+type ImageProps = PD.Values<typeof ImageParams>
+
+const HorizontalGradientParams = {
+    topColor: PD.Color(Color(0xDDDDDD)),
+    bottomColor: PD.Color(Color(0xEEEEEE)),
+    ratio: PD.Numeric(0.5, { min: 0.0, max: 1.0, step: 0.01 }),
+    coverage: PD.Select('viewport', PD.arrayToOptions(['viewport', 'canvas'])),
+};
+
+const RadialGradientParams = {
+    centerColor: PD.Color(Color(0xDDDDDD)),
+    edgeColor: PD.Color(Color(0xEEEEEE)),
+    ratio: PD.Numeric(0.5, { min: 0.0, max: 1.0, step: 0.01 }),
+    coverage: PD.Select('viewport', PD.arrayToOptions(['viewport', 'canvas'])),
+};
+
+export const BackgroundParams = {
+    variant: PD.MappedStatic('off', {
+        off: PD.EmptyGroup(),
+        skybox: PD.Group(SkyboxParams, { isExpanded: true }),
+        image: PD.Group(ImageParams, { isExpanded: true }),
+        horizontalGradient: PD.Group(HorizontalGradientParams, { isExpanded: true }),
+        radialGradient: PD.Group(RadialGradientParams, { isExpanded: true }),
+    }, { label: 'Environment' }),
+};
+export type BackgroundProps = PD.Values<typeof BackgroundParams>
+
+export class BackgroundPass {
+    private renderable: BackgroundRenderable;
+
+    private skybox: {
+        texture: Texture
+        props: SkyboxProps
+        assets: Asset[]
+        loaded: boolean
+    } | undefined;
+
+    private image: {
+        texture: Texture
+        props: ImageProps
+        asset: Asset
+        loaded: boolean
+    } | undefined;
+
+    private readonly camera = new Camera();
+    private readonly target = Vec3();
+    private readonly position = Vec3();
+    private readonly dir = Vec3();
+
+    readonly texture: Texture;
+
+    constructor(private readonly webgl: WebGLContext, private readonly assetManager: AssetManager, width: number, height: number) {
+        this.renderable = getBackgroundRenderable(webgl, width, height);
+    }
+
+    setSize(width: number, height: number) {
+        const [w, h] = this.renderable.values.uTexSize.ref.value;
+
+        if (width !== w || height !== h) {
+            ValueCell.update(this.renderable.values.uTexSize, Vec2.set(this.renderable.values.uTexSize.ref.value, width, height));
+        }
+    }
+
+    private clearSkybox() {
+        if (this.skybox !== undefined) {
+            this.skybox.texture.destroy();
+            this.skybox.assets.forEach(a => this.assetManager.release(a));
+            this.skybox = undefined;
+        }
+    }
+
+    private updateSkybox(camera: ICamera, props: SkyboxProps, onload?: (changed: boolean) => void) {
+        const tf = this.skybox?.props.faces;
+        const f = props.faces.params;
+        if (!f.nx || !f.ny || !f.nz || !f.px || !f.py || !f.pz) {
+            this.clearSkybox();
+            onload?.(false);
+            return;
+        }
+        if (!this.skybox || !tf || !areSkyboxTexturePropsEqual(props.faces, this.skybox.props.faces)) {
+            this.clearSkybox();
+            const { texture, assets } = getSkyboxTexture(this.webgl, this.assetManager, props.faces, errored => {
+                if (this.skybox) this.skybox.loaded = !errored;
+                onload?.(true);
+            });
+            this.skybox = { texture, props: { ...props }, assets, loaded: false };
+            ValueCell.update(this.renderable.values.tSkybox, texture);
+            this.renderable.update();
+        } else {
+            onload?.(false);
+        }
+        if (!this.skybox) return;
+
+        let cam = camera;
+        if (camera.state.mode === 'orthographic') {
+            this.camera.setState({ ...camera.state, mode: 'perspective' });
+            this.camera.update();
+            cam = this.camera;
+        }
+
+        const m = this.renderable.values.uViewDirectionProjectionInverse.ref.value;
+        Vec3.sub(this.dir, cam.state.position, cam.state.target);
+        Vec3.setMagnitude(this.dir, this.dir, 0.1);
+        Vec3.copy(this.position, this.dir);
+        Mat4.lookAt(m, this.position, this.target, cam.state.up);
+        Mat4.mul(m, cam.projection, m);
+        Mat4.invert(m, m);
+        ValueCell.update(this.renderable.values.uViewDirectionProjectionInverse, m);
+
+        ValueCell.updateIfChanged(this.renderable.values.uOpacity, props.opacity);
+        ValueCell.updateIfChanged(this.renderable.values.uSaturation, props.saturation);
+        ValueCell.updateIfChanged(this.renderable.values.uLightness, props.lightness);
+        ValueCell.updateIfChanged(this.renderable.values.dVariant, 'skybox');
+        this.renderable.update();
+    }
+
+    private clearImage() {
+        if (this.image !== undefined) {
+            this.image.texture.destroy();
+            this.assetManager.release(this.image.asset);
+            this.image = undefined;
+        }
+    }
+
+    private updateImage(props: ImageProps, onload?: (loaded: boolean) => void) {
+        if (!props.source.params) {
+            this.clearImage();
+            onload?.(false);
+            return;
+        }
+        if (!this.image || !this.image.props.source.params || !areImageTexturePropsEqual(props.source, this.image.props.source)) {
+            this.clearImage();
+            const { texture, asset } = getImageTexture(this.webgl, this.assetManager, props.source, errored => {
+                if (this.image) this.image.loaded = !errored;
+                onload?.(true);
+            });
+            this.image = { texture, props: { ...props }, asset, loaded: false };
+            ValueCell.update(this.renderable.values.tImage, texture);
+            this.renderable.update();
+        } else {
+            onload?.(false);
+        }
+        if (!this.image) return;
+
+        ValueCell.updateIfChanged(this.renderable.values.uOpacity, props.opacity);
+        ValueCell.updateIfChanged(this.renderable.values.uSaturation, props.saturation);
+        ValueCell.updateIfChanged(this.renderable.values.uLightness, props.lightness);
+        ValueCell.updateIfChanged(this.renderable.values.uViewportAdjusted, props.coverage === 'viewport' ? true : false);
+        ValueCell.updateIfChanged(this.renderable.values.dVariant, 'image');
+        this.renderable.update();
+    }
+
+    private updateImageScaling() {
+        const v = this.renderable.values;
+        const [w, h] = v.uTexSize.ref.value;
+        const iw = this.image?.texture.getWidth() || 0;
+        const ih = this.image?.texture.getHeight() || 0;
+        const r = w / h;
+        const ir = iw / ih;
+        // responsive scaling with offset
+        if (r < ir) {
+            ValueCell.update(v.uImageScale, Vec2.set(v.uImageScale.ref.value, iw * h / ih, h));
+        } else {
+            ValueCell.update(v.uImageScale, Vec2.set(v.uImageScale.ref.value, w, ih * w / iw));
+        }
+        const [rw, rh] = v.uImageScale.ref.value;
+        const sr = rw / rh;
+        if (sr > r) {
+            ValueCell.update(v.uImageOffset, Vec2.set(v.uImageOffset.ref.value, (1 - r / sr) / 2, 0));
+        } else {
+            ValueCell.update(v.uImageOffset, Vec2.set(v.uImageOffset.ref.value, 0, (1 - sr / r) / 2));
+        }
+    }
+
+    private updateGradient(colorA: Color, colorB: Color, ratio: number, variant: 'horizontalGradient' | 'radialGradient', viewportAdjusted: boolean) {
+        ValueCell.update(this.renderable.values.uGradientColorA, Color.toVec3Normalized(this.renderable.values.uGradientColorA.ref.value, colorA));
+        ValueCell.update(this.renderable.values.uGradientColorB, Color.toVec3Normalized(this.renderable.values.uGradientColorB.ref.value, colorB));
+        ValueCell.updateIfChanged(this.renderable.values.uGradientRatio, ratio);
+        ValueCell.updateIfChanged(this.renderable.values.uViewportAdjusted, viewportAdjusted);
+        ValueCell.updateIfChanged(this.renderable.values.dVariant, variant);
+        this.renderable.update();
+    }
+
+    update(camera: ICamera, props: BackgroundProps, onload?: (changed: boolean) => void) {
+        if (props.variant.name === 'off') {
+            this.clearSkybox();
+            this.clearImage();
+            onload?.(false);
+            return;
+        } else if (props.variant.name === 'skybox') {
+            this.clearImage();
+            this.updateSkybox(camera, props.variant.params, onload);
+        } else if (props.variant.name === 'image') {
+            this.clearSkybox();
+            this.updateImage(props.variant.params, onload);
+        } else if (props.variant.name === 'horizontalGradient') {
+            this.clearSkybox();
+            this.clearImage();
+            this.updateGradient(props.variant.params.topColor, props.variant.params.bottomColor, props.variant.params.ratio, props.variant.name, props.variant.params.coverage === 'viewport' ? true : false);
+            onload?.(false);
+        } else if (props.variant.name === 'radialGradient') {
+            this.clearSkybox();
+            this.clearImage();
+            this.updateGradient(props.variant.params.centerColor, props.variant.params.edgeColor, props.variant.params.ratio, props.variant.name, props.variant.params.coverage === 'viewport' ? true : false);
+            onload?.(false);
+        }
+
+        const { x, y, width, height } = camera.viewport;
+        ValueCell.update(this.renderable.values.uViewport, Vec4.set(this.renderable.values.uViewport.ref.value, x, y, width, height));
+    }
+
+    isEnabled(props: BackgroundProps) {
+        return !!(
+            (this.skybox && this.skybox.loaded) ||
+            (this.image && this.image.loaded) ||
+            props.variant.name === 'horizontalGradient' ||
+            props.variant.name === 'radialGradient'
+        );
+    }
+
+    private isReady() {
+        return !!(
+            (this.skybox && this.skybox.loaded) ||
+            (this.image && this.image.loaded) ||
+            this.renderable.values.dVariant.ref.value === 'horizontalGradient' ||
+            this.renderable.values.dVariant.ref.value === 'radialGradient'
+        );
+    }
+
+    render() {
+        if (!this.isReady()) return;
+
+        if (this.renderable.values.dVariant.ref.value === 'image') {
+            this.updateImageScaling();
+        }
+
+        if (isTimingMode) this.webgl.timer.mark('BackgroundPass.render');
+        this.renderable.render();
+        if (isTimingMode) this.webgl.timer.markEnd('BackgroundPass.render');
+    }
+
+    dispose() {
+        this.clearSkybox();
+        this.clearImage();
+    }
+}
+
+//
+
+const SkyboxName = 'background-skybox';
+
+type CubeAssets = { [k in keyof CubeFaces]: Asset };
+
+function getCubeAssets(assetManager: AssetManager, faces: SkyboxProps['faces']): CubeAssets {
+    if (faces.name === 'urls') {
+        return {
+            nx: Asset.getUrlAsset(assetManager, faces.params.nx),
+            ny: Asset.getUrlAsset(assetManager, faces.params.ny),
+            nz: Asset.getUrlAsset(assetManager, faces.params.nz),
+            px: Asset.getUrlAsset(assetManager, faces.params.px),
+            py: Asset.getUrlAsset(assetManager, faces.params.py),
+            pz: Asset.getUrlAsset(assetManager, faces.params.pz),
+        };
+    } else {
+        return {
+            nx: faces.params.nx!,
+            ny: faces.params.ny!,
+            nz: faces.params.nz!,
+            px: faces.params.px!,
+            py: faces.params.py!,
+            pz: faces.params.pz!,
+        };
+    }
+}
+
+function getCubeFaces(assetManager: AssetManager, cubeAssets: CubeAssets): CubeFaces {
+    const resolve = (asset: Asset) => {
+        return assetManager.resolve(asset, 'binary').run().then(a => new Blob([a.data]));
+    };
+
+    return {
+        nx: resolve(cubeAssets.nx),
+        ny: resolve(cubeAssets.ny),
+        nz: resolve(cubeAssets.nz),
+        px: resolve(cubeAssets.px),
+        py: resolve(cubeAssets.py),
+        pz: resolve(cubeAssets.pz),
+    };
+}
+
+function getSkyboxHash(faces: SkyboxProps['faces']) {
+    if (faces.name === 'urls') {
+        return `${SkyboxName}_${faces.params.nx}|${faces.params.ny}|${faces.params.nz}|${faces.params.px}|${faces.params.py}|${faces.params.pz}`;
+    } else {
+        return `${SkyboxName}_${faces.params.nx?.id}|${faces.params.ny?.id}|${faces.params.nz?.id}|${faces.params.px?.id}|${faces.params.py?.id}|${faces.params.pz?.id}`;
+    }
+}
+
+function areSkyboxTexturePropsEqual(facesA: SkyboxProps['faces'], facesB: SkyboxProps['faces']) {
+    return getSkyboxHash(facesA) === getSkyboxHash(facesB);
+}
+
+function getSkyboxTexture(ctx: WebGLContext, assetManager: AssetManager, faces: SkyboxProps['faces'], onload?: (errored?: boolean) => void): { texture: Texture, assets: Asset[] } {
+    const cubeAssets = getCubeAssets(assetManager, faces);
+    const cubeFaces = getCubeFaces(assetManager, cubeAssets);
+    const assets = [cubeAssets.nx, cubeAssets.ny, cubeAssets.nz, cubeAssets.px, cubeAssets.py, cubeAssets.pz];
+    const texture = ctx.resources.cubeTexture(cubeFaces, false, onload);
+    return { texture, assets };
+}
+
+//
+
+const ImageName = 'background-image';
+
+function getImageHash(source: ImageProps['source']) {
+    if (source.name === 'url') {
+        return `${ImageName}_${source.params}`;
+    } else {
+        return `${ImageName}_${source.params?.id}`;
+    }
+}
+
+function areImageTexturePropsEqual(sourceA: ImageProps['source'], sourceB: ImageProps['source']) {
+    return getImageHash(sourceA) === getImageHash(sourceB);
+}
+
+function getImageTexture(ctx: WebGLContext, assetManager: AssetManager, source: ImageProps['source'], onload?: (errored?: boolean) => void): { texture: Texture, asset: Asset } {
+    const texture = ctx.resources.texture('image-uint8', 'rgba', 'ubyte', 'linear');
+    const img = new Image();
+    img.onload = () => {
+        texture.load(img);
+        onload?.();
+    };
+    img.onerror = () => {
+        onload?.(true);
+    };
+    const asset = source.name === 'url'
+        ? Asset.getUrlAsset(assetManager, source.params)
+        : source.params!;
+    assetManager.resolve(asset, 'binary').run().then(a => {
+        const blob = new Blob([a.data]);
+        img.src = URL.createObjectURL(blob);
+    });
+    return { texture, asset };
+}
+
+//
+
+const BackgroundSchema = {
+    drawCount: ValueSpec('number'),
+    instanceCount: ValueSpec('number'),
+    aPosition: AttributeSpec('float32', 2, 0),
+    tSkybox: TextureSpec('texture', 'rgba', 'ubyte', 'linear'),
+    tImage: TextureSpec('texture', 'rgba', 'ubyte', 'linear'),
+    uImageScale: UniformSpec('v2'),
+    uImageOffset: UniformSpec('v2'),
+    uTexSize: UniformSpec('v2'),
+    uViewport: UniformSpec('v4'),
+    uViewportAdjusted: UniformSpec('b'),
+    uViewDirectionProjectionInverse: UniformSpec('m4'),
+    uGradientColorA: UniformSpec('v3'),
+    uGradientColorB: UniformSpec('v3'),
+    uGradientRatio: UniformSpec('f'),
+    uOpacity: UniformSpec('f'),
+    uSaturation: UniformSpec('f'),
+    uLightness: UniformSpec('f'),
+    dVariant: DefineSpec('string', ['skybox', 'image', 'verticalGradient', 'horizontalGradient', 'radialGradient']),
+};
+const SkyboxShaderCode = ShaderCode('background', background_vert, background_frag);
+type BackgroundRenderable = ComputeRenderable<Values<typeof BackgroundSchema>>
+
+function getBackgroundRenderable(ctx: WebGLContext, width: number, height: number): BackgroundRenderable {
+    const values: Values<typeof BackgroundSchema> = {
+        drawCount: ValueCell.create(6),
+        instanceCount: ValueCell.create(1),
+        aPosition: ValueCell.create(QuadPositions),
+        tSkybox: ValueCell.create(createNullTexture()),
+        tImage: ValueCell.create(createNullTexture()),
+        uImageScale: ValueCell.create(Vec2()),
+        uImageOffset: ValueCell.create(Vec2()),
+        uTexSize: ValueCell.create(Vec2.create(width, height)),
+        uViewport: ValueCell.create(Vec4()),
+        uViewportAdjusted: ValueCell.create(true),
+        uViewDirectionProjectionInverse: ValueCell.create(Mat4()),
+        uGradientColorA: ValueCell.create(Vec3()),
+        uGradientColorB: ValueCell.create(Vec3()),
+        uGradientRatio: ValueCell.create(0.5),
+        uOpacity: ValueCell.create(1),
+        uSaturation: ValueCell.create(0),
+        uLightness: ValueCell.create(0),
+        dVariant: ValueCell.create('skybox'),
+    };
+
+    const schema = { ...BackgroundSchema };
+    const renderItem = createComputeRenderItem(ctx, 'triangles', SkyboxShaderCode, schema, values);
+
+    return createComputeRenderable(renderItem, values);
+}
diff --git a/src/mol-canvas3d/passes/draw.ts b/src/mol-canvas3d/passes/draw.ts
index 0bb002d84c9df136b5bb8ce43025f07b8b583652..8b1d82c088a35b364fca04340d3811b55dbb5c9c 100644
--- a/src/mol-canvas3d/passes/draw.ts
+++ b/src/mol-canvas3d/passes/draw.ts
@@ -21,10 +21,11 @@ import { AntialiasingPass, PostprocessingPass, PostprocessingProps } from './pos
 import { MarkingPass, MarkingProps } from './marking';
 import { CopyRenderable, createCopyRenderable } from '../../mol-gl/compute/util';
 import { isTimingMode } from '../../mol-util/debug';
+import { AssetManager } from '../../mol-util/assets';
 
 type Props = {
-    postprocessing: PostprocessingProps
-    marking: MarkingProps
+    postprocessing: PostprocessingProps;
+    marking: MarkingProps;
     transparentBackground: boolean;
 }
 
@@ -50,7 +51,7 @@ export class DrawPass {
     private copyFboTarget: CopyRenderable;
     private copyFboPostprocessing: CopyRenderable;
 
-    private wboit: WboitPass | undefined;
+    private readonly wboit: WboitPass | undefined;
     private readonly marking: MarkingPass;
     readonly postprocessing: PostprocessingPass;
     private readonly antialiasing: AntialiasingPass;
@@ -59,11 +60,10 @@ export class DrawPass {
         return !!this.wboit?.supported;
     }
 
-    constructor(private webgl: WebGLContext, width: number, height: number, enableWboit: boolean) {
+    constructor(private webgl: WebGLContext, assetManager: AssetManager, width: number, height: number, enableWboit: boolean) {
         const { extensions, resources, isWebGL2 } = webgl;
 
         this.drawTarget = createNullRenderTarget(webgl.gl);
-
         this.colorTarget = webgl.createRenderTarget(width, height, true, 'uint8', 'linear');
         this.packedDepth = !extensions.depthTexture;
 
@@ -79,7 +79,7 @@ export class DrawPass {
 
         this.wboit = enableWboit ? new WboitPass(webgl, width, height) : undefined;
         this.marking = new MarkingPass(webgl, width, height);
-        this.postprocessing = new PostprocessingPass(webgl, this);
+        this.postprocessing = new PostprocessingPass(webgl, assetManager, this);
         this.antialiasing = new AntialiasingPass(webgl, this);
 
         this.copyFboTarget = createCopyRenderable(webgl, this.colorTarget.texture);
@@ -120,14 +120,13 @@ export class DrawPass {
     private _renderWboit(renderer: Renderer, camera: ICamera, scene: Scene, transparentBackground: boolean, postprocessingProps: PostprocessingProps) {
         if (!this.wboit?.supported) throw new Error('expected wboit to be supported');
 
-        this.colorTarget.bind();
+        this.depthTextureOpaque.attachFramebuffer(this.colorTarget.framebuffer, 'depth');
         renderer.clear(true);
 
         // render opaque primitives
-        this.depthTextureOpaque.attachFramebuffer(this.colorTarget.framebuffer, 'depth');
-        this.colorTarget.bind();
-        renderer.clearDepth();
-        renderer.renderWboitOpaque(scene.primitives, camera, null);
+        if (scene.hasOpaque) {
+            renderer.renderWboitOpaque(scene.primitives, camera, null);
+        }
 
         if (PostprocessingPass.isEnabled(postprocessingProps)) {
             if (PostprocessingPass.isOutlineEnabled(postprocessingProps)) {
@@ -165,14 +164,17 @@ export class DrawPass {
         if (toDrawingBuffer) {
             this.drawTarget.bind();
         } else {
-            this.colorTarget.bind();
             if (!this.packedDepth) {
                 this.depthTextureOpaque.attachFramebuffer(this.colorTarget.framebuffer, 'depth');
+            } else {
+                this.colorTarget.bind();
             }
         }
 
         renderer.clear(true);
-        renderer.renderBlendedOpaque(scene.primitives, camera, null);
+        if (scene.hasOpaque) {
+            renderer.renderBlendedOpaque(scene.primitives, camera, null);
+        }
 
         if (!toDrawingBuffer) {
             // do a depth pass if not rendering to drawing buffer and
@@ -235,7 +237,7 @@ export class DrawPass {
         }
     }
 
-    private _render(renderer: Renderer, camera: ICamera, scene: Scene, helper: Helper, toDrawingBuffer: boolean, props: Props) {
+    private _render(renderer: Renderer, camera: ICamera, scene: Scene, helper: Helper, toDrawingBuffer: boolean, transparentBackground: boolean, props: Props) {
         const volumeRendering = scene.volumes.renderables.length > 0;
         const postprocessingEnabled = PostprocessingPass.isEnabled(props.postprocessing);
         const antialiasingEnabled = AntialiasingPass.isEnabled(props.postprocessing);
@@ -245,54 +247,52 @@ export class DrawPass {
         renderer.setViewport(x, y, width, height);
         renderer.update(camera);
 
-        if (props.transparentBackground && !antialiasingEnabled && toDrawingBuffer) {
+        if (transparentBackground && !antialiasingEnabled && toDrawingBuffer) {
             this.drawTarget.bind();
             renderer.clear(false);
         }
 
         if (this.wboitEnabled) {
-            this._renderWboit(renderer, camera, scene, props.transparentBackground, props.postprocessing);
-        } else {
-            this._renderBlended(renderer, camera, scene, !volumeRendering && !postprocessingEnabled && !antialiasingEnabled && toDrawingBuffer, props.transparentBackground, props.postprocessing);
-        }
-
-        if (postprocessingEnabled) {
-            this.postprocessing.target.bind();
-        } else if (!toDrawingBuffer || volumeRendering || this.wboitEnabled) {
-            this.colorTarget.bind();
+            this._renderWboit(renderer, camera, scene, transparentBackground, props.postprocessing);
         } else {
-            this.drawTarget.bind();
+            this._renderBlended(renderer, camera, scene, !volumeRendering && !postprocessingEnabled && !antialiasingEnabled && toDrawingBuffer, transparentBackground, props.postprocessing);
         }
 
-        if (markingEnabled) {
-            if (scene.markerAverage > 0) {
-                const markingDepthTest = props.marking.ghostEdgeStrength < 1;
-                if (markingDepthTest && scene.markerAverage !== 1) {
-                    this.marking.depthTarget.bind();
-                    renderer.clear(false, true);
-                    renderer.renderMarkingDepth(scene.primitives, camera, null);
-                }
+        const target = postprocessingEnabled
+            ? this.postprocessing.target
+            : !toDrawingBuffer || volumeRendering || this.wboitEnabled
+                ? this.colorTarget
+                : this.drawTarget;
 
-                this.marking.maskTarget.bind();
+        if (markingEnabled && scene.markerAverage > 0) {
+            const markingDepthTest = props.marking.ghostEdgeStrength < 1;
+            if (markingDepthTest && scene.markerAverage !== 1) {
+                this.marking.depthTarget.bind();
                 renderer.clear(false, true);
-                renderer.renderMarkingMask(scene.primitives, camera, markingDepthTest ? this.marking.depthTarget.texture : null);
-
-                this.marking.update(props.marking);
-                this.marking.render(camera.viewport, postprocessingEnabled ? this.postprocessing.target : this.colorTarget);
+                renderer.renderMarkingDepth(scene.primitives, camera, null);
             }
+
+            this.marking.maskTarget.bind();
+            renderer.clear(false, true);
+            renderer.renderMarkingMask(scene.primitives, camera, markingDepthTest ? this.marking.depthTarget.texture : null);
+
+            this.marking.update(props.marking);
+            this.marking.render(camera.viewport, target);
+        } else {
+            target.bind();
         }
 
         if (helper.debug.isEnabled) {
             helper.debug.syncVisibility();
-            renderer.renderBlended(helper.debug.scene, camera, null);
+            renderer.renderBlended(helper.debug.scene, camera);
         }
         if (helper.handle.isEnabled) {
-            renderer.renderBlended(helper.handle.scene, camera, null);
+            renderer.renderBlended(helper.handle.scene, camera);
         }
         if (helper.camera.isEnabled) {
             helper.camera.update(camera);
             renderer.update(helper.camera.camera);
-            renderer.renderBlended(helper.camera.scene, helper.camera.camera, null);
+            renderer.renderBlended(helper.camera.scene, helper.camera.camera);
         }
 
         if (antialiasingEnabled) {
@@ -314,15 +314,19 @@ export class DrawPass {
     render(ctx: RenderContext, props: Props, toDrawingBuffer: boolean) {
         if (isTimingMode) this.webgl.timer.mark('DrawPass.render');
         const { renderer, camera, scene, helper } = ctx;
-        renderer.setTransparentBackground(props.transparentBackground);
+
+        this.postprocessing.setTransparentBackground(props.transparentBackground);
+        const transparentBackground = props.transparentBackground || this.postprocessing.background.isEnabled(props.postprocessing.background);
+
+        renderer.setTransparentBackground(transparentBackground);
         renderer.setDrawingBufferSize(this.colorTarget.getWidth(), this.colorTarget.getHeight());
         renderer.setPixelRatio(this.webgl.pixelRatio);
 
         if (StereoCamera.is(camera)) {
-            this._render(renderer, camera.left, scene, helper, toDrawingBuffer, props);
-            this._render(renderer, camera.right, scene, helper, toDrawingBuffer, props);
+            this._render(renderer, camera.left, scene, helper, toDrawingBuffer, transparentBackground, props);
+            this._render(renderer, camera.right, scene, helper, toDrawingBuffer, transparentBackground, props);
         } else {
-            this._render(renderer, camera, scene, helper, toDrawingBuffer, props);
+            this._render(renderer, camera, scene, helper, toDrawingBuffer, transparentBackground, props);
         }
         if (isTimingMode) this.webgl.timer.markEnd('DrawPass.render');
     }
diff --git a/src/mol-canvas3d/passes/fxaa.ts b/src/mol-canvas3d/passes/fxaa.ts
index ff1a0e878775ca6a898c0983382e10ac834c9091..bbb02430284d72c25be9f93b76bf863970e018ee 100644
--- a/src/mol-canvas3d/passes/fxaa.ts
+++ b/src/mol-canvas3d/passes/fxaa.ts
@@ -44,8 +44,8 @@ export class FxaaPass {
         state.depthMask(false);
 
         const { x, y, width, height } = viewport;
-        gl.viewport(x, y, width, height);
-        gl.scissor(x, y, width, height);
+        state.viewport(x, y, width, height);
+        state.scissor(x, y, width, height);
 
         state.clearColor(0, 0, 0, 1);
         gl.clear(gl.COLOR_BUFFER_BIT);
diff --git a/src/mol-canvas3d/passes/image.ts b/src/mol-canvas3d/passes/image.ts
index 68acfa4c4b6977b36669d0b3a7afdbc6b01b96a2..e78aca0a31a447c5aa0f874dbf1995a13304c026 100644
--- a/src/mol-canvas3d/passes/image.ts
+++ b/src/mol-canvas3d/passes/image.ts
@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2019-2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2019-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
  */
@@ -18,6 +18,7 @@ import { PixelData } from '../../mol-util/image';
 import { Helper } from '../helper/helper';
 import { CameraHelper, CameraHelperParams } from '../helper/camera-helper';
 import { MarkingParams } from './marking';
+import { AssetManager } from '../../mol-util/assets';
 
 export const ImageParams = {
     transparentBackground: PD.Boolean(false),
@@ -47,10 +48,10 @@ export class ImagePass {
     get width() { return this._width; }
     get height() { return this._height; }
 
-    constructor(private webgl: WebGLContext, private renderer: Renderer, private scene: Scene, private camera: Camera, helper: Helper, enableWboit: boolean, props: Partial<ImageProps>) {
+    constructor(private webgl: WebGLContext, assetManager: AssetManager, private renderer: Renderer, private scene: Scene, private camera: Camera, helper: Helper, enableWboit: boolean, props: Partial<ImageProps>) {
         this.props = { ...PD.getDefaultValues(ImageParams), ...props };
 
-        this.drawPass = new DrawPass(webgl, 128, 128, enableWboit);
+        this.drawPass = new DrawPass(webgl, assetManager, 128, 128, enableWboit);
         this.multiSamplePass = new MultiSamplePass(webgl, this.drawPass);
         this.multiSampleHelper = new MultiSampleHelper(this.multiSamplePass);
 
@@ -63,6 +64,14 @@ export class ImagePass {
         this.setSize(1024, 768);
     }
 
+    updateBackground() {
+        return new Promise<void>(resolve => {
+            this.drawPass.postprocessing.background.update(this.camera, this.props.postprocessing.background, () => {
+                resolve();
+            });
+        });
+    }
+
     setSize(width: number, height: number) {
         if (width === this._width && height === this._height) return;
 
diff --git a/src/mol-canvas3d/passes/marking.ts b/src/mol-canvas3d/passes/marking.ts
index 73cde519fa611d2160c7a2c1788c5120146cf1e4..2093b5f2d1e2d0f818fbdd6519c2021f7654218d 100644
--- a/src/mol-canvas3d/passes/marking.ts
+++ b/src/mol-canvas3d/passes/marking.ts
@@ -64,8 +64,8 @@ export class MarkingPass {
         state.depthMask(false);
 
         const { x, y, width, height } = viewport;
-        gl.viewport(x, y, width, height);
-        gl.scissor(x, y, width, height);
+        state.viewport(x, y, width, height);
+        state.scissor(x, y, width, height);
 
         state.clearColor(0, 0, 0, 0);
         gl.clear(gl.COLOR_BUFFER_BIT);
@@ -82,8 +82,8 @@ export class MarkingPass {
         state.depthMask(false);
 
         const { x, y, width, height } = viewport;
-        gl.viewport(x, y, width, height);
-        gl.scissor(x, y, width, height);
+        state.viewport(x, y, width, height);
+        state.scissor(x, y, width, height);
     }
 
     setSize(width: number, height: number) {
diff --git a/src/mol-canvas3d/passes/multi-sample.ts b/src/mol-canvas3d/passes/multi-sample.ts
index 82c861372d1c7677531d3861298695e764014da4..2137592b6ea329ea5d705e5cf13d3d162b14eecc 100644
--- a/src/mol-canvas3d/passes/multi-sample.ts
+++ b/src/mol-canvas3d/passes/multi-sample.ts
@@ -176,8 +176,8 @@ export class MultiSamplePass {
             state.blendFuncSeparate(gl.ONE, gl.ONE, gl.ONE, gl.ONE);
             state.disable(gl.DEPTH_TEST);
             state.depthMask(false);
-            gl.viewport(x, y, width, height);
-            gl.scissor(x, y, width, height);
+            state.viewport(x, y, width, height);
+            state.scissor(x, y, width, height);
             if (i === 0) {
                 state.clearColor(0, 0, 0, 0);
                 gl.clear(gl.COLOR_BUFFER_BIT);
@@ -192,8 +192,8 @@ export class MultiSamplePass {
         compose.update();
 
         this.bindOutputTarget(toDrawingBuffer);
-        gl.viewport(x, y, width, height);
-        gl.scissor(x, y, width, height);
+        state.viewport(x, y, width, height);
+        state.scissor(x, y, width, height);
 
         state.disable(gl.BLEND);
         compose.render();
@@ -231,8 +231,8 @@ export class MultiSamplePass {
             state.disable(gl.BLEND);
             state.disable(gl.DEPTH_TEST);
             state.depthMask(false);
-            gl.viewport(x, y, width, height);
-            gl.scissor(x, y, width, height);
+            state.viewport(x, y, width, height);
+            state.scissor(x, y, width, height);
             compose.render();
             sampleIndex += 1;
         } else {
@@ -267,8 +267,8 @@ export class MultiSamplePass {
                 state.blendFuncSeparate(gl.ONE, gl.ONE, gl.ONE, gl.ONE);
                 state.disable(gl.DEPTH_TEST);
                 state.depthMask(false);
-                gl.viewport(x, y, width, height);
-                gl.scissor(x, y, width, height);
+                state.viewport(x, y, width, height);
+                state.scissor(x, y, width, height);
                 if (sampleIndex === 0) {
                     state.clearColor(0, 0, 0, 0);
                     gl.clear(gl.COLOR_BUFFER_BIT);
@@ -283,8 +283,8 @@ export class MultiSamplePass {
         drawPass.postprocessing.setOcclusionOffset(0, 0);
 
         this.bindOutputTarget(toDrawingBuffer);
-        gl.viewport(x, y, width, height);
-        gl.scissor(x, y, width, height);
+        state.viewport(x, y, width, height);
+        state.scissor(x, y, width, height);
 
         const accumulationWeight = sampleIndex * sampleWeight;
         if (accumulationWeight > 0) {
diff --git a/src/mol-canvas3d/passes/passes.ts b/src/mol-canvas3d/passes/passes.ts
index 208795e33bb2af60d8966f2f857fb87dc594087a..117bb6ef0526f68074b40bf08b0f437d88c5ba88 100644
--- a/src/mol-canvas3d/passes/passes.ts
+++ b/src/mol-canvas3d/passes/passes.ts
@@ -8,15 +8,16 @@ import { DrawPass } from './draw';
 import { PickPass } from './pick';
 import { MultiSamplePass } from './multi-sample';
 import { WebGLContext } from '../../mol-gl/webgl/context';
+import { AssetManager } from '../../mol-util/assets';
 
 export class Passes {
     readonly draw: DrawPass;
     readonly pick: PickPass;
     readonly multiSample: MultiSamplePass;
 
-    constructor(private webgl: WebGLContext, attribs: Partial<{ pickScale: number, enableWboit: boolean }> = {}) {
+    constructor(private webgl: WebGLContext, assetManager: AssetManager, attribs: Partial<{ pickScale: number, enableWboit: boolean }> = {}) {
         const { gl } = webgl;
-        this.draw = new DrawPass(webgl, gl.drawingBufferWidth, gl.drawingBufferHeight, attribs.enableWboit || false);
+        this.draw = new DrawPass(webgl, assetManager, gl.drawingBufferWidth, gl.drawingBufferHeight, attribs.enableWboit || false);
         this.pick = new PickPass(webgl, this.draw, attribs.pickScale || 0.25);
         this.multiSample = new MultiSamplePass(webgl, this.draw);
     }
diff --git a/src/mol-canvas3d/passes/postprocessing.ts b/src/mol-canvas3d/passes/postprocessing.ts
index fd41b8abe62071ec6a85734b6db554753e1ed03d..591517976863a47013460eb3158cfeeb699619a4 100644
--- a/src/mol-canvas3d/passes/postprocessing.ts
+++ b/src/mol-canvas3d/passes/postprocessing.ts
@@ -28,6 +28,8 @@ import { Color } from '../../mol-util/color';
 import { FxaaParams, FxaaPass } from './fxaa';
 import { SmaaParams, SmaaPass } from './smaa';
 import { isTimingMode } from '../../mol-util/debug';
+import { BackgroundParams, BackgroundPass } from './background';
+import { AssetManager } from '../../mol-util/assets';
 
 const OutlinesSchema = {
     ...QuadSchema,
@@ -91,7 +93,7 @@ function getSsaoRenderable(ctx: WebGLContext, depthTexture: Texture): SsaoRender
         ...QuadValues,
         tDepth: ValueCell.create(depthTexture),
 
-        uSamples: ValueCell.create([0.0, 0.0, 1.0]),
+        uSamples: ValueCell.create(getSamples(32)),
         dNSamples: ValueCell.create(32),
 
         uProjection: ValueCell.create(Mat4.identity()),
@@ -138,7 +140,7 @@ function getSsaoBlurRenderable(ctx: WebGLContext, ssaoDepthTexture: Texture, dir
         tSsaoDepth: ValueCell.create(ssaoDepthTexture),
         uTexSize: ValueCell.create(Vec2.create(ssaoDepthTexture.getWidth(), ssaoDepthTexture.getHeight())),
 
-        uKernel: ValueCell.create([0.0]),
+        uKernel: ValueCell.create(getBlurKernel(15)),
         dOcclusionKernelSize: ValueCell.create(15),
 
         uBlurDirectionX: ValueCell.create(direction === 'horizontal' ? 1 : 0),
@@ -171,15 +173,26 @@ function getBlurKernel(kernelSize: number): number[] {
     return kernel;
 }
 
-function getSamples(vectorSamples: Vec3[], nSamples: number): number[] {
+const RandomHemisphereVector: Vec3[] = [];
+for (let i = 0; i < 256; i++) {
+    const v = Vec3();
+    v[0] = Math.random() * 2.0 - 1.0;
+    v[1] = Math.random() * 2.0 - 1.0;
+    v[2] = Math.random();
+    Vec3.normalize(v, v);
+    Vec3.scale(v, v, Math.random());
+    RandomHemisphereVector.push(v);
+}
+
+function getSamples(nSamples: number): number[] {
     const samples = [];
     for (let i = 0; i < nSamples; i++) {
         let scale = (i * i + 2.0 * i + 1) / (nSamples * nSamples);
         scale = 0.1 + scale * (1.0 - 0.1);
 
-        samples.push(vectorSamples[i][0] * scale);
-        samples.push(vectorSamples[i][1] * scale);
-        samples.push(vectorSamples[i][2] * scale);
+        samples.push(RandomHemisphereVector[i][0] * scale);
+        samples.push(RandomHemisphereVector[i][1] * scale);
+        samples.push(RandomHemisphereVector[i][2] * scale);
     }
 
     return samples;
@@ -274,12 +287,13 @@ export const PostprocessingParams = {
         smaa: PD.Group(SmaaParams),
         off: PD.Group({})
     }, { options: [['fxaa', 'FXAA'], ['smaa', 'SMAA'], ['off', 'Off']], description: 'Smooth pixel edges' }),
+    background: PD.Group(BackgroundParams, { isFlat: true }),
 };
 export type PostprocessingProps = PD.Values<typeof PostprocessingParams>
 
 export class PostprocessingPass {
     static isEnabled(props: PostprocessingProps) {
-        return props.occlusion.name === 'on' || props.outline.name === 'on';
+        return props.occlusion.name === 'on' || props.outline.name === 'on' || props.background.variant.name !== 'off';
     }
 
     static isOutlineEnabled(props: PostprocessingProps) {
@@ -291,7 +305,6 @@ export class PostprocessingPass {
     private readonly outlinesTarget: RenderTarget;
     private readonly outlinesRenderable: OutlinesRenderable;
 
-    private readonly randomHemisphereVector: Vec3[];
     private readonly ssaoFramebuffer: Framebuffer;
     private readonly ssaoBlurFirstPassFramebuffer: Framebuffer;
     private readonly ssaoBlurSecondPassFramebuffer: Framebuffer;
@@ -318,7 +331,10 @@ export class PostprocessingPass {
         return Math.min(1, 1 / this.webgl.pixelRatio) * this.downsampleFactor;
     }
 
-    constructor(private webgl: WebGLContext, private drawPass: DrawPass) {
+    private readonly bgColor = Vec3();
+    readonly background: BackgroundPass;
+
+    constructor(private readonly webgl: WebGLContext, assetManager: AssetManager, private readonly drawPass: DrawPass) {
         const { colorTarget, depthTextureTransparent, depthTextureOpaque } = drawPass;
         const width = colorTarget.getWidth();
         const height = colorTarget.getHeight();
@@ -334,16 +350,6 @@ export class PostprocessingPass {
         this.outlinesTarget = webgl.createRenderTarget(width, height, false);
         this.outlinesRenderable = getOutlinesRenderable(webgl, depthTextureOpaque, depthTextureTransparent);
 
-        this.randomHemisphereVector = [];
-        for (let i = 0; i < 256; i++) {
-            const v = Vec3();
-            v[0] = Math.random() * 2.0 - 1.0;
-            v[1] = Math.random() * 2.0 - 1.0;
-            v[2] = Math.random();
-            Vec3.normalize(v, v);
-            Vec3.scale(v, v, Math.random());
-            this.randomHemisphereVector.push(v);
-        }
         this.ssaoFramebuffer = webgl.resources.framebuffer();
         this.ssaoBlurFirstPassFramebuffer = webgl.resources.framebuffer();
         this.ssaoBlurSecondPassFramebuffer = webgl.resources.framebuffer();
@@ -368,6 +374,8 @@ export class PostprocessingPass {
         this.ssaoBlurFirstPassRenderable = getSsaoBlurRenderable(webgl, this.ssaoDepthTexture, 'horizontal');
         this.ssaoBlurSecondPassRenderable = getSsaoBlurRenderable(webgl, this.ssaoDepthBlurProxyTexture, 'vertical');
         this.renderable = getPostprocessingRenderable(webgl, colorTarget.texture, depthTextureOpaque, depthTextureTransparent, this.outlinesTarget.texture, this.ssaoDepthTexture);
+
+        this.background = new BackgroundPass(webgl, assetManager, width, height);
     }
 
     setSize(width: number, height: number) {
@@ -391,6 +399,8 @@ export class PostprocessingPass {
             ValueCell.update(this.ssaoRenderable.values.uTexSize, Vec2.set(this.ssaoRenderable.values.uTexSize.ref.value, sw, sh));
             ValueCell.update(this.ssaoBlurFirstPassRenderable.values.uTexSize, Vec2.set(this.ssaoBlurFirstPassRenderable.values.uTexSize.ref.value, sw, sh));
             ValueCell.update(this.ssaoBlurSecondPassRenderable.values.uTexSize, Vec2.set(this.ssaoBlurSecondPassRenderable.values.uTexSize.ref.value, sw, sh));
+
+            this.background.setSize(width, height);
         }
     }
 
@@ -440,7 +450,7 @@ export class PostprocessingPass {
                 needsUpdateSsao = true;
 
                 this.nSamples = props.occlusion.params.samples;
-                ValueCell.update(this.ssaoRenderable.values.uSamples, getSamples(this.randomHemisphereVector, this.nSamples));
+                ValueCell.update(this.ssaoRenderable.values.uSamples, getSamples(this.nSamples));
                 ValueCell.updateIfChanged(this.ssaoRenderable.values.dNSamples, this.nSamples);
             }
             ValueCell.updateIfChanged(this.ssaoRenderable.values.uRadius, Math.pow(2, props.occlusion.params.radius));
@@ -538,8 +548,8 @@ export class PostprocessingPass {
         state.depthMask(false);
 
         const { x, y, width, height } = camera.viewport;
-        gl.viewport(x, y, width, height);
-        gl.scissor(x, y, width, height);
+        state.viewport(x, y, width, height);
+        state.scissor(x, y, width, height);
     }
 
     private occlusionOffset: [x: number, y: number] = [0, 0];
@@ -549,6 +559,11 @@ export class PostprocessingPass {
         ValueCell.update(this.renderable.values.uOcclusionOffset, Vec2.set(this.renderable.values.uOcclusionOffset.ref.value, x, y));
     }
 
+    private transparentBackground = false;
+    setTransparentBackground(value: boolean) {
+        this.transparentBackground = value;
+    }
+
     render(camera: ICamera, toDrawingBuffer: boolean, transparentBackground: boolean, backgroundColor: Color, props: PostprocessingProps) {
         if (isTimingMode) this.webgl.timer.mark('PostprocessingPass.render');
         this.updateState(camera, transparentBackground, backgroundColor, props);
@@ -583,8 +598,23 @@ export class PostprocessingPass {
         }
 
         const { gl, state } = this.webgl;
-        state.clearColor(0, 0, 0, 1);
-        gl.clear(gl.COLOR_BUFFER_BIT);
+
+        this.background.update(camera, props.background);
+        if (this.background.isEnabled(props.background)) {
+            if (this.transparentBackground) {
+                state.clearColor(0, 0, 0, 0);
+            } else {
+                Color.toVec3Normalized(this.bgColor, backgroundColor);
+                state.clearColor(this.bgColor[0], this.bgColor[1], this.bgColor[2], 1);
+            }
+            gl.clear(gl.COLOR_BUFFER_BIT);
+            state.enable(gl.BLEND);
+            state.blendFuncSeparate(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA, gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
+            this.background.render();
+        } else {
+            state.clearColor(0, 0, 0, 1);
+            gl.clear(gl.COLOR_BUFFER_BIT);
+        }
 
         this.renderable.render();
         if (isTimingMode) this.webgl.timer.markEnd('PostprocessingPass.render');
diff --git a/src/mol-canvas3d/passes/smaa.ts b/src/mol-canvas3d/passes/smaa.ts
index 3002b2ff33f3b4bd244675dc264ec4aa49f30c36..4ac7296fa717dcb72e3c790fd027275c6a5177b5 100644
--- a/src/mol-canvas3d/passes/smaa.ts
+++ b/src/mol-canvas3d/passes/smaa.ts
@@ -71,8 +71,8 @@ export class SmaaPass {
         state.depthMask(false);
 
         const { x, y, width, height } = viewport;
-        gl.viewport(x, y, width, height);
-        gl.scissor(x, y, width, height);
+        state.viewport(x, y, width, height);
+        state.scissor(x, y, width, height);
 
         state.clearColor(0, 0, 0, 1);
         gl.clear(gl.COLOR_BUFFER_BIT);
diff --git a/src/mol-canvas3d/passes/wboit.ts b/src/mol-canvas3d/passes/wboit.ts
index c78f809a1e4b619d866b14557a936bbf4a0ffd15..210a94f51c1efb0ecd5d95a5932b6f2d958e4351 100644
--- a/src/mol-canvas3d/passes/wboit.ts
+++ b/src/mol-canvas3d/passes/wboit.ts
@@ -18,6 +18,8 @@ import { evaluateWboit_frag } from '../../mol-gl/shader/evaluate-wboit.frag';
 import { Framebuffer } from '../../mol-gl/webgl/framebuffer';
 import { Vec2 } from '../../mol-math/linear-algebra';
 import { isDebugMode, isTimingMode } from '../../mol-util/debug';
+import { isWebGL2 } from '../../mol-gl/webgl/compat';
+import { Renderbuffer } from '../../mol-gl/webgl/renderbuffer';
 
 const EvaluateWboitSchema = {
     ...QuadSchema,
@@ -50,6 +52,7 @@ export class WboitPass {
     private readonly framebuffer: Framebuffer;
     private readonly textureA: Texture;
     private readonly textureB: Texture;
+    private readonly depthRenderbuffer: Renderbuffer;
 
     private _supported = false;
     get supported() {
@@ -87,6 +90,7 @@ export class WboitPass {
         if (width !== w || height !== h) {
             this.textureA.define(width, height);
             this.textureB.define(width, height);
+            this.depthRenderbuffer.setSize(width, height);
             ValueCell.update(this.renderable.values.uTexSize, Vec2.set(this.renderable.values.uTexSize.ref.value, width, height));
         }
     }
@@ -106,6 +110,8 @@ export class WboitPass {
 
         this.textureA.attachFramebuffer(this.framebuffer, 'color0');
         this.textureB.attachFramebuffer(this.framebuffer, 'color1');
+
+        this.depthRenderbuffer.attachFramebuffer(this.framebuffer);
     }
 
     static isSupported(webgl: WebGLContext) {
@@ -128,7 +134,7 @@ export class WboitPass {
     constructor(private webgl: WebGLContext, width: number, height: number) {
         if (!WboitPass.isSupported(webgl)) return;
 
-        const { resources } = webgl;
+        const { resources, gl } = webgl;
 
         this.textureA = resources.texture('image-float32', 'rgba', 'float', 'nearest');
         this.textureA.define(width, height);
@@ -136,6 +142,10 @@ export class WboitPass {
         this.textureB = resources.texture('image-float32', 'rgba', 'float', 'nearest');
         this.textureB.define(width, height);
 
+        this.depthRenderbuffer = isWebGL2(gl)
+            ? resources.renderbuffer('depth32f', 'depth', width, height)
+            : resources.renderbuffer('depth16', 'depth', width, height);
+
         this.renderable = getEvaluateWboitRenderable(webgl, this.textureA, this.textureB);
         this.framebuffer = resources.framebuffer();
 
diff --git a/src/mol-geo/geometry/texture-mesh/color-smoothing.ts b/src/mol-geo/geometry/texture-mesh/color-smoothing.ts
index 0a0a1786c5d2ae9412e09f698b33db3be74f7aea..6a9983a8f45c4556617cc63cb650f28bc6442b34 100644
--- a/src/mol-geo/geometry/texture-mesh/color-smoothing.ts
+++ b/src/mol-geo/geometry/texture-mesh/color-smoothing.ts
@@ -319,8 +319,8 @@ export function calcTextureMeshColorSmoothing(input: ColorSmoothingInput, resolu
 
     if (isTimingMode) webgl.timer.mark('ColorAccumulate.render');
     setAccumulateDefaults(webgl);
-    gl.viewport(0, 0, width, height);
-    gl.scissor(0, 0, width, height);
+    state.viewport(0, 0, width, height);
+    state.scissor(0, 0, width, height);
     gl.clear(gl.COLOR_BUFFER_BIT);
     ValueCell.update(uCurrentY, 0);
     let currCol = 0;
@@ -336,8 +336,8 @@ export function calcTextureMeshColorSmoothing(input: ColorSmoothingInput, resolu
         // console.log({ i, currX, currY });
         ValueCell.update(uCurrentX, currX);
         ValueCell.update(uCurrentSlice, i);
-        gl.viewport(currX, currY, dx, dy);
-        gl.scissor(currX, currY, dx, dy);
+        state.viewport(currX, currY, dx, dy);
+        state.scissor(currX, currY, dx, dy);
         accumulateRenderable.render();
         ++currCol;
         currX += dx;
@@ -371,8 +371,8 @@ export function calcTextureMeshColorSmoothing(input: ColorSmoothingInput, resolu
 
     setNormalizeDefaults(webgl);
     texture.attachFramebuffer(framebuffer, 0);
-    gl.viewport(0, 0, width, height);
-    gl.scissor(0, 0, width, height);
+    state.viewport(0, 0, width, height);
+    state.scissor(0, 0, width, height);
     gl.clear(gl.COLOR_BUFFER_BIT);
     normalizeRenderable.render();
     if (isTimingMode) webgl.timer.markEnd('ColorNormalize.render');
diff --git a/src/mol-gl/compute/grid3d.ts b/src/mol-gl/compute/grid3d.ts
index e291661ccae12a921b5d127ce6ecdbbc22617ce5..6a4922e34a6dcc497fdb5f672d94318fe0afe601 100644
--- a/src/mol-gl/compute/grid3d.ts
+++ b/src/mol-gl/compute/grid3d.ts
@@ -225,8 +225,8 @@ export function createGrid3dComputeRenderable<S extends RenderableSchema, P, CS>
 
 function resetGl(webgl: WebGLContext, w: number) {
     const { gl, state } = webgl;
-    gl.viewport(0, 0, w, w);
-    gl.scissor(0, 0, w, w);
+    state.viewport(0, 0, w, w);
+    state.scissor(0, 0, w, w);
     state.disable(gl.SCISSOR_TEST);
     state.disable(gl.BLEND);
     state.disable(gl.DEPTH_TEST);
diff --git a/src/mol-gl/compute/histogram-pyramid/reduction.ts b/src/mol-gl/compute/histogram-pyramid/reduction.ts
index 8d162f5e99e8e69cf1f5fcb188a956336682d59f..b95ad7c44933a9398687654794311b74b52e8ddf 100644
--- a/src/mol-gl/compute/histogram-pyramid/reduction.ts
+++ b/src/mol-gl/compute/histogram-pyramid/reduction.ts
@@ -122,7 +122,7 @@ export interface HistogramPyramid {
 
 export function createHistogramPyramid(ctx: WebGLContext, inputTexture: Texture, scale: Vec2, gridTexDim: Vec3): HistogramPyramid {
     if (isTimingMode) ctx.timer.mark('createHistogramPyramid');
-    const { gl } = ctx;
+    const { gl, state } = ctx;
     const w = inputTexture.getWidth();
     const h = inputTexture.getHeight();
 
@@ -146,7 +146,7 @@ export function createHistogramPyramid(ctx: WebGLContext, inputTexture: Texture,
     const framebuffer = getFramebuffer('pyramid', ctx);
     pyramidTex.attachFramebuffer(framebuffer, 0);
 
-    gl.viewport(0, 0, maxSizeX, maxSizeY);
+    state.viewport(0, 0, maxSizeX, maxSizeY);
     if (isWebGL2(gl)) {
         gl.clearBufferiv(gl.COLOR, 0, [0, 0, 0, 0]);
     } else {
@@ -157,7 +157,7 @@ export function createHistogramPyramid(ctx: WebGLContext, inputTexture: Texture,
     for (let i = 0; i < levels; ++i) levelTexturesFramebuffers.push(getLevelTextureFramebuffer(ctx, i));
 
     const renderable = getHistopyramidReductionRenderable(ctx, inputTexture, levelTexturesFramebuffers[0].texture);
-    ctx.state.currentRenderItemId = -1;
+    state.currentRenderItemId = -1;
     setRenderingDefaults(ctx);
 
     let offset = 0;
@@ -176,15 +176,15 @@ export function createHistogramPyramid(ctx: WebGLContext, inputTexture: Texture,
             ValueCell.update(renderable.values.tPreviousLevel, levelTexturesFramebuffers[levels - i].texture);
             renderable.update();
         }
-        ctx.state.currentRenderItemId = -1;
-        gl.viewport(0, 0, size, size);
-        gl.scissor(0, 0, size, size);
+        state.currentRenderItemId = -1;
+        state.viewport(0, 0, size, size);
+        state.scissor(0, 0, size, size);
         if (isWebGL2(gl)) {
             gl.clearBufferiv(gl.COLOR, 0, [0, 0, 0, 0]);
         } else {
             gl.clear(gl.COLOR_BUFFER_BIT);
         }
-        gl.scissor(0, 0, gridTexDim[0], gridTexDim[1]);
+        state.scissor(0, 0, gridTexDim[0], gridTexDim[1]);
         renderable.render();
 
         pyramidTex.bind(0);
diff --git a/src/mol-gl/compute/histogram-pyramid/sum.ts b/src/mol-gl/compute/histogram-pyramid/sum.ts
index a1cd5919a7bf5632b87d6ca0c8ea274ecff1caf4..65c36515d80ac9d2f1f7e3e87118c90fe240bcaf 100644
--- a/src/mol-gl/compute/histogram-pyramid/sum.ts
+++ b/src/mol-gl/compute/histogram-pyramid/sum.ts
@@ -68,7 +68,7 @@ const sumInts = new Int32Array(4);
 
 export function getHistopyramidSum(ctx: WebGLContext, pyramidTopTexture: Texture) {
     if (isTimingMode) ctx.timer.mark('getHistopyramidSum');
-    const { gl, resources } = ctx;
+    const { gl, state, resources } = ctx;
 
     const renderable = getHistopyramidSumRenderable(ctx, pyramidTopTexture);
     ctx.state.currentRenderItemId = -1;
@@ -89,7 +89,7 @@ export function getHistopyramidSum(ctx: WebGLContext, pyramidTopTexture: Texture
 
     setRenderingDefaults(ctx);
 
-    gl.viewport(0, 0, 1, 1);
+    state.viewport(0, 0, 1, 1);
     renderable.render();
     gl.finish();
 
diff --git a/src/mol-gl/compute/marching-cubes/active-voxels.ts b/src/mol-gl/compute/marching-cubes/active-voxels.ts
index b16014c011b8eef48a74c800b69c595b463bdee7..c460512d509d791b3ebfc5eba5d07afefa7f32ef 100644
--- a/src/mol-gl/compute/marching-cubes/active-voxels.ts
+++ b/src/mol-gl/compute/marching-cubes/active-voxels.ts
@@ -85,7 +85,7 @@ function setRenderingDefaults(ctx: WebGLContext) {
 
 export function calcActiveVoxels(ctx: WebGLContext, volumeData: Texture, gridDim: Vec3, gridTexDim: Vec3, isoValue: number, gridScale: Vec2) {
     if (isTimingMode) ctx.timer.mark('calcActiveVoxels');
-    const { gl, resources } = ctx;
+    const { gl, state, resources } = ctx;
     const width = volumeData.getWidth();
     const height = volumeData.getHeight();
 
@@ -106,10 +106,10 @@ export function calcActiveVoxels(ctx: WebGLContext, volumeData: Texture, gridDim
 
     activeVoxelsTex.attachFramebuffer(framebuffer, 0);
     setRenderingDefaults(ctx);
-    gl.viewport(0, 0, width, height);
-    gl.scissor(0, 0, width, height);
+    state.viewport(0, 0, width, height);
+    state.scissor(0, 0, width, height);
     gl.clear(gl.COLOR_BUFFER_BIT);
-    gl.scissor(0, 0, gridTexDim[0], gridTexDim[1]);
+    state.scissor(0, 0, gridTexDim[0], gridTexDim[1]);
     renderable.render();
 
     // console.log('gridScale', gridScale, 'gridTexDim', gridTexDim, 'gridDim', gridDim);
diff --git a/src/mol-gl/compute/marching-cubes/isosurface.ts b/src/mol-gl/compute/marching-cubes/isosurface.ts
index 3c628b25554fbd79c14f59fee00d2fbf236c3c7c..0215937e512ad4839c8b5dc9fb9b16fe1cf044e1 100644
--- a/src/mol-gl/compute/marching-cubes/isosurface.ts
+++ b/src/mol-gl/compute/marching-cubes/isosurface.ts
@@ -127,7 +127,7 @@ export function createIsosurfaceBuffers(ctx: WebGLContext, activeVoxelsBase: Tex
     if (!drawBuffers) throw new Error('need WebGL draw buffers');
 
     if (isTimingMode) ctx.timer.mark('createIsosurfaceBuffers');
-    const { gl, resources, extensions } = ctx;
+    const { gl, state, resources, extensions } = ctx;
     const { pyramidTex, height, levels, scale, count } = histogramPyramid;
     const width = pyramidTex.getWidth();
 
@@ -192,7 +192,7 @@ export function createIsosurfaceBuffers(ctx: WebGLContext, activeVoxelsBase: Tex
     ]);
 
     setRenderingDefaults(ctx);
-    gl.viewport(0, 0, width, height);
+    state.viewport(0, 0, width, height);
     gl.clear(gl.COLOR_BUFFER_BIT);
     renderable.render();
 
diff --git a/src/mol-gl/compute/util.ts b/src/mol-gl/compute/util.ts
index 2e759efbb3def192fe627db0c448a0b81ba39bc5..bfef65236a7a6ee14392f4f0f50f2fc1da56cac0 100644
--- a/src/mol-gl/compute/util.ts
+++ b/src/mol-gl/compute/util.ts
@@ -125,8 +125,8 @@ export function readAlphaTexture(ctx: WebGLContext, texture: Texture) {
     state.clearColor(0, 0, 0, 0);
     state.blendFunc(gl.ONE, gl.ONE);
     state.blendEquation(gl.FUNC_ADD);
-    gl.viewport(0, 0, width, height);
-    gl.scissor(0, 0, width, height);
+    state.viewport(0, 0, width, height);
+    state.scissor(0, 0, width, height);
     gl.clear(gl.COLOR_BUFFER_BIT);
     copy.render();
 
diff --git a/src/mol-gl/renderer.ts b/src/mol-gl/renderer.ts
index 259e99e16651bed9b82cced4f10bcc610e0d3e4d..2182662f3675347955f025390e9892d948904ab4 100644
--- a/src/mol-gl/renderer.ts
+++ b/src/mol-gl/renderer.ts
@@ -64,7 +64,7 @@ interface Renderer {
     renderDepthTransparent: (group: Scene.Group, camera: ICamera, depthTexture: Texture | null) => void
     renderMarkingDepth: (group: Scene.Group, camera: ICamera, depthTexture: Texture | null) => void
     renderMarkingMask: (group: Scene.Group, camera: ICamera, depthTexture: Texture | null) => void
-    renderBlended: (group: Scene.Group, camera: ICamera, depthTexture: Texture | null) => void
+    renderBlended: (group: Scene, camera: ICamera) => void
     renderBlendedOpaque: (group: Scene.Group, camera: ICamera, depthTexture: Texture | null) => void
     renderBlendedTransparent: (group: Scene.Group, camera: ICamera, depthTexture: Texture | null) => void
     renderBlendedVolume: (group: Scene.Group, camera: ICamera, depthTexture: Texture | null) => void
@@ -359,8 +359,8 @@ namespace Renderer {
             state.colorMask(true, true, true, true);
 
             const { x, y, width, height } = viewport;
-            gl.viewport(x, y, width, height);
-            gl.scissor(x, y, width, height);
+            state.viewport(x, y, width, height);
+            state.scissor(x, y, width, height);
 
             globalUniformsNeedUpdate = true;
             state.currentRenderItemId = -1;
@@ -475,9 +475,13 @@ namespace Renderer {
             if (isTimingMode) ctx.timer.markEnd('Renderer.renderMarkingMask');
         };
 
-        const renderBlended = (group: Scene.Group, camera: ICamera, depthTexture: Texture | null) => {
-            renderBlendedOpaque(group, camera, depthTexture);
-            renderBlendedTransparent(group, camera, depthTexture);
+        const renderBlended = (scene: Scene, camera: ICamera) => {
+            if (scene.hasOpaque) {
+                renderBlendedOpaque(scene, camera, null);
+            }
+            if (scene.opacityAverage < 1) {
+                renderBlendedTransparent(scene, camera, null);
+            }
         };
 
         const renderBlendedOpaque = (group: Scene.Group, camera: ICamera, depthTexture: Texture | null) => {
@@ -591,7 +595,7 @@ namespace Renderer {
                 // TODO: simplify, handle in renderable.state???
                 // uAlpha is updated in "render" so we need to recompute it here
                 const alpha = clamp(r.values.alpha.ref.value * r.state.alphaFactor, 0, 1);
-                if (alpha < 1 || r.values.transparencyAverage.ref.value > 0 || r.values.dGeometryType.ref.value === 'directVolume' || r.values.dPointStyle?.ref.value === 'fuzzy' || !!r.values.uBackgroundColor || r.values.dXrayShaded?.ref.value) {
+                if (alpha < 1 || r.values.transparencyAverage.ref.value > 0 || r.values.dGeometryType.ref.value === 'directVolume' || r.values.dPointStyle?.ref.value === 'fuzzy' || r.values.dGeometryType.ref.value === 'text' || r.values.dXrayShaded?.ref.value) {
                     renderObject(r, 'colorWboit', Flag.None);
                 }
             }
@@ -714,8 +718,8 @@ namespace Renderer {
                 }
             },
             setViewport: (x: number, y: number, width: number, height: number) => {
-                gl.viewport(x, y, width, height);
-                gl.scissor(x, y, width, height);
+                state.viewport(x, y, width, height);
+                state.scissor(x, y, width, height);
                 if (x !== viewport.x || y !== viewport.y || width !== viewport.width || height !== viewport.height) {
                     Viewport.set(viewport, x, y, width, height);
                     ValueCell.update(globalUniforms.uViewport, Vec4.set(globalUniforms.uViewport.ref.value, x, y, width, height));
diff --git a/src/mol-gl/scene.ts b/src/mol-gl/scene.ts
index 21f8cd529798cfaf739f8ae3249e5b14ab289cf7..a46be8ae129f3be7d75e5dd6e821181757923386 100644
--- a/src/mol-gl/scene.ts
+++ b/src/mol-gl/scene.ts
@@ -80,8 +80,12 @@ interface Scene extends Object3D {
     has: (o: GraphicsRenderObject) => boolean
     clear: () => void
     forEach: (callbackFn: (value: GraphicsRenderable, key: GraphicsRenderObject) => void) => void
+    /** Marker average of primitive renderables */
     readonly markerAverage: number
+    /** Opacity average of primitive renderables */
     readonly opacityAverage: number
+    /** Is `true` if any primitive renderable (possibly) has any opaque part */
+    readonly hasOpaque: boolean
 }
 
 namespace Scene {
@@ -103,6 +107,7 @@ namespace Scene {
 
         let markerAverage = 0;
         let opacityAverage = 0;
+        let hasOpaque = false;
 
         const object3d = Object3D.create();
         const { view, position, direction, up } = object3d;
@@ -160,7 +165,9 @@ namespace Scene {
             }
 
             renderables.sort(renderableSort);
+            markerAverage = calculateMarkerAverage();
             opacityAverage = calculateOpacityAverage();
+            hasOpaque = calculateHasOpaque();
             return true;
         }
 
@@ -182,7 +189,10 @@ namespace Scene {
             const newVisibleHash = computeVisibleHash();
             if (newVisibleHash !== visibleHash) {
                 boundingSphereVisibleDirty = true;
+                markerAverage = calculateMarkerAverage();
                 opacityAverage = calculateOpacityAverage();
+                hasOpaque = calculateHasOpaque();
+                visibleHash = newVisibleHash;
                 return true;
             } else {
                 return false;
@@ -212,12 +222,27 @@ namespace Scene {
                 // uAlpha is updated in "render" so we need to recompute it here
                 const alpha = clamp(p.values.alpha.ref.value * p.state.alphaFactor, 0, 1);
                 const xray = p.values.dXrayShaded?.ref.value ? 0.5 : 1;
-                opacityAverage += (1 - p.values.transparencyAverage.ref.value) * alpha * xray;
+                const fuzzy = p.values.dPointStyle?.ref.value === 'fuzzy' ? 0.5 : 1;
+                const text = p.values.dGeometryType.ref.value === 'text' ? 0.5 : 1;
+                opacityAverage += (1 - p.values.transparencyAverage.ref.value) * alpha * xray * fuzzy * text;
                 count += 1;
             }
             return count > 0 ? opacityAverage / count : 0;
         }
 
+        function calculateHasOpaque() {
+            if (primitives.length === 0) return false;
+            for (let i = 0, il = primitives.length; i < il; ++i) {
+                const p = primitives[i];
+                if (!p.state.visible) continue;
+
+                if (p.state.opaque) return true;
+                if (p.state.alphaFactor === 1 && p.values.alpha.ref.value === 1 && p.values.transparencyAverage.ref.value !== 1) return true;
+                if (p.values.dTransparentBackfaces?.ref.value === 'opaque') return true;
+            }
+            return false;
+        }
+
         return {
             view, position, direction, up,
 
@@ -245,6 +270,7 @@ namespace Scene {
                 }
                 markerAverage = calculateMarkerAverage();
                 opacityAverage = calculateOpacityAverage();
+                hasOpaque = calculateHasOpaque();
             },
             add: (o: GraphicsRenderObject) => commitQueue.add(o),
             remove: (o: GraphicsRenderObject) => commitQueue.remove(o),
@@ -281,7 +307,6 @@ namespace Scene {
                 if (boundingSphereVisibleDirty) {
                     calculateBoundingSphere(renderables, boundingSphereVisible, true);
                     boundingSphereVisibleDirty = false;
-                    visibleHash = computeVisibleHash();
                 }
                 return boundingSphereVisible;
             },
@@ -291,6 +316,9 @@ namespace Scene {
             get opacityAverage() {
                 return opacityAverage;
             },
+            get hasOpaque() {
+                return hasOpaque;
+            },
         };
     }
 }
diff --git a/src/mol-gl/shader-code.ts b/src/mol-gl/shader-code.ts
index c65f6df5dd080bcee9075dfabf0275b990f479db..a99cf5b31cafaac10f2d9352c82d79b3f908efeb 100644
--- a/src/mol-gl/shader-code.ts
+++ b/src/mol-gl/shader-code.ts
@@ -292,7 +292,9 @@ const glsl300VertPrefixCommon = `
 const glsl300FragPrefixCommon = `
 #define varying in
 #define texture2D texture
+#define textureCube texture
 #define texture2DLodEXT textureLod
+#define textureCubeLodEXT textureLod
 
 #define gl_FragColor out_FragData0
 #define gl_FragDepthEXT gl_FragDepth
diff --git a/src/mol-gl/shader/background.frag.ts b/src/mol-gl/shader/background.frag.ts
new file mode 100644
index 0000000000000000000000000000000000000000..a764a9ad8237ec635bb53ad45c7bb7e08f297c4f
--- /dev/null
+++ b/src/mol-gl/shader/background.frag.ts
@@ -0,0 +1,85 @@
+export const background_frag = `
+precision mediump float;
+precision mediump samplerCube;
+precision mediump sampler2D;
+
+#if defined(dVariant_skybox)
+    uniform samplerCube tSkybox;
+    uniform mat4 uViewDirectionProjectionInverse;
+    uniform float uOpacity;
+    uniform float uSaturation;
+    uniform float uLightness;
+#elif defined(dVariant_image)
+    uniform sampler2D tImage;
+    uniform vec2 uImageScale;
+    uniform vec2 uImageOffset;
+    uniform float uOpacity;
+    uniform float uSaturation;
+    uniform float uLightness;
+#elif defined(dVariant_horizontalGradient) || defined(dVariant_radialGradient)
+    uniform vec3 uGradientColorA;
+    uniform vec3 uGradientColorB;
+    uniform float uGradientRatio;
+#endif
+
+uniform vec2 uTexSize;
+uniform vec4 uViewport;
+uniform bool uViewportAdjusted;
+varying vec4 vPosition;
+
+// TODO: add as general pp option to remove banding?
+// Iestyn's RGB dither from http://alex.vlachos.com/graphics/Alex_Vlachos_Advanced_VR_Rendering_GDC2015.pdf
+vec3 ScreenSpaceDither(vec2 vScreenPos) {
+    vec3 vDither = vec3(dot(vec2(171.0, 231.0), vScreenPos.xy));
+    vDither.rgb = fract(vDither.rgb / vec3(103.0, 71.0, 97.0));
+    return vDither.rgb / 255.0;
+}
+
+vec3 saturateColor(vec3 c, float amount) {
+    // https://www.w3.org/TR/WCAG21/#dfn-relative-luminance
+    const vec3 W = vec3(0.2125, 0.7154, 0.0721);
+    vec3 intensity = vec3(dot(c, W));
+    return mix(intensity, c, 1.0 + amount);
+}
+
+vec3 lightenColor(vec3 c, float amount) {
+    return c + amount;
+}
+
+void main() {
+    #if defined(dVariant_skybox)
+        vec4 t = uViewDirectionProjectionInverse * vPosition;
+        gl_FragColor = textureCube(tSkybox, normalize(t.xyz / t.w));
+        gl_FragColor.a = uOpacity;
+        gl_FragColor.rgb = lightenColor(saturateColor(gl_FragColor.rgb, uSaturation), uLightness);
+    #elif defined(dVariant_image)
+        vec2 coords;
+        if (uViewportAdjusted) {
+            coords = ((gl_FragCoord.xy - uViewport.xy) * (uTexSize / uViewport.zw) / uImageScale) + uImageOffset;
+        } else {
+            coords = (gl_FragCoord.xy / uImageScale) + uImageOffset;
+        }
+        gl_FragColor = texture2D(tImage, vec2(coords.x, 1.0 - coords.y));
+        gl_FragColor.a = uOpacity;
+        gl_FragColor.rgb = lightenColor(saturateColor(gl_FragColor.rgb, uSaturation), uLightness);
+    #elif defined(dVariant_horizontalGradient)
+        float d;
+        if (uViewportAdjusted) {
+            d = ((gl_FragCoord.y - uViewport.y) * (uTexSize.y / uViewport.w) / uTexSize.y) + 1.0 - (uGradientRatio * 2.0);
+        } else {
+            d = (gl_FragCoord.y / uTexSize.y) + 1.0 - (uGradientRatio * 2.0);
+        }
+        gl_FragColor = vec4(mix(uGradientColorB, uGradientColorA, clamp(d, 0.0, 1.0)), 1.0);
+        gl_FragColor.rgb += ScreenSpaceDither(gl_FragCoord.xy);
+    #elif defined(dVariant_radialGradient)
+        float d;
+        if (uViewportAdjusted) {
+            d = distance(vec2(0.5), (gl_FragCoord.xy - uViewport.xy) * (uTexSize / uViewport.zw) / uTexSize) + uGradientRatio - 0.5;
+        } else {
+            d = distance(vec2(0.5), gl_FragCoord.xy / uTexSize) + uGradientRatio - 0.5;
+        }
+        gl_FragColor = vec4(mix(uGradientColorB, uGradientColorA, 1.0 - clamp(d, 0.0, 1.0)), 1.0);
+        gl_FragColor.rgb += ScreenSpaceDither(gl_FragCoord.xy);
+    #endif
+}
+`;
diff --git a/src/mol-gl/shader/background.vert.ts b/src/mol-gl/shader/background.vert.ts
new file mode 100644
index 0000000000000000000000000000000000000000..3f1b86fbbf861b84d577cd73c6e5e1a32908c114
--- /dev/null
+++ b/src/mol-gl/shader/background.vert.ts
@@ -0,0 +1,12 @@
+export const background_vert = `
+precision mediump float;
+
+attribute vec2 aPosition;
+
+varying vec4 vPosition;
+
+void main() {
+    vPosition = vec4(aPosition, 1.0, 1.0);
+    gl_Position = vec4(aPosition, 1.0, 1.0);
+}
+`;
diff --git a/src/mol-gl/shader/chunks/color-frag-params.glsl.ts b/src/mol-gl/shader/chunks/color-frag-params.glsl.ts
index 4027046f94f18175e895cf440b82aa6623e470ae..b5802f3688be2243b93853b1556226235d1af0bb 100644
--- a/src/mol-gl/shader/chunks/color-frag-params.glsl.ts
+++ b/src/mol-gl/shader/chunks/color-frag-params.glsl.ts
@@ -36,7 +36,6 @@ uniform float uBumpiness;
             varying vec4 vColor;
         #endif
     #else
-        // avoid flat until EXT_provoking_vertex is supported
         #ifdef requiredDrawBuffers
             flat in vec4 vObject;
             flat in vec4 vInstance;
diff --git a/src/mol-gl/shader/chunks/color-vert-params.glsl.ts b/src/mol-gl/shader/chunks/color-vert-params.glsl.ts
index 0b4c5c45f230aa15a3e846936d23596ae0312ee6..30a830ad73651982363871cbb3fe8b1fcc20d431 100644
--- a/src/mol-gl/shader/chunks/color-vert-params.glsl.ts
+++ b/src/mol-gl/shader/chunks/color-vert-params.glsl.ts
@@ -64,7 +64,6 @@ uniform float uBumpiness;
             varying vec4 vColor;
         #endif
     #else
-        // avoid flat until EXT_provoking_vertex is supported
         #ifdef requiredDrawBuffers
             flat out vec4 vObject;
             flat out vec4 vInstance;
diff --git a/src/mol-gl/shader/chunks/common-frag-params.glsl.ts b/src/mol-gl/shader/chunks/common-frag-params.glsl.ts
index 5722cb8a2c3ed77e340bcf3354fee9b771f5fdf5..3d694f3e2601c37631439f9e2d5a7f640aa3315b 100644
--- a/src/mol-gl/shader/chunks/common-frag-params.glsl.ts
+++ b/src/mol-gl/shader/chunks/common-frag-params.glsl.ts
@@ -17,7 +17,6 @@ uniform int uMarkingType;
         #if __VERSION__ == 100 || defined(dClippingType_instance) || !defined(dVaryingGroup)
             varying float vClipping;
         #else
-            // avoid flat until EXT_provoking_vertex is supported
             flat in float vClipping;
         #endif
     #endif
@@ -36,7 +35,6 @@ uniform int uMarkingType;
     #if __VERSION__ == 100 || defined(dMarkerType_instance) || !defined(dVaryingGroup)
         varying float vMarker;
     #else
-        // avoid flat until EXT_provoking_vertex is supported
         flat in float vMarker;
     #endif
 #endif
diff --git a/src/mol-gl/shader/chunks/common-vert-params.glsl.ts b/src/mol-gl/shader/chunks/common-vert-params.glsl.ts
index 2e391530c42fd9340e6bf3af92c1d379bccfe787..4589093a99daffa056212e269b990bda4e3b3665 100644
--- a/src/mol-gl/shader/chunks/common-vert-params.glsl.ts
+++ b/src/mol-gl/shader/chunks/common-vert-params.glsl.ts
@@ -24,7 +24,6 @@ uniform int uPickType;
         #if __VERSION__ == 100 || defined(dClippingType_instance) || !defined(dVaryingGroup)
             varying float vClipping;
         #else
-            // avoid flat until EXT_provoking_vertex is supported
             flat out float vClipping;
         #endif
     #endif
@@ -37,7 +36,6 @@ uniform int uPickType;
     #if __VERSION__ == 100 || defined(dMarkerType_instance) || !defined(dVaryingGroup)
         varying float vMarker;
     #else
-        // avoid flat until EXT_provoking_vertex is supported
         flat out float vMarker;
     #endif
 #endif
@@ -46,7 +44,9 @@ varying vec3 vModelPosition;
 varying vec3 vViewPosition;
 
 #if defined(noNonInstancedActiveAttribs)
-    #define VertexID gl_VertexID
+    // int() is needed for some Safari versions
+    // see https://bugs.webkit.org/show_bug.cgi?id=244152
+    #define VertexID int(gl_VertexID)
 #else
     attribute float aVertex;
     #define VertexID int(aVertex)
diff --git a/src/mol-gl/shader/cylinders.frag.ts b/src/mol-gl/shader/cylinders.frag.ts
index 82c0f8ca738db0fbc2be2ef44cbb9b146e294173..8f840be543d2df2174a33c66772727d639a608a2 100644
--- a/src/mol-gl/shader/cylinders.frag.ts
+++ b/src/mol-gl/shader/cylinders.frag.ts
@@ -109,14 +109,14 @@ void main() {
 
     vec3 vViewPosition = vModelPosition + intersection.x * rayDir;
     vViewPosition = (uView * vec4(vViewPosition, 1.0)).xyz;
-    gl_FragDepthEXT = calcDepth(vViewPosition);
+    float fragmentDepth = calcDepth(vViewPosition);
 
-    vec3 vModelPosition = (uInvView * vec4(vViewPosition, 1.0)).xyz;
+    if (fragmentDepth < 0.0) discard;
+    if (fragmentDepth > 1.0) discard;
 
-    if (gl_FragDepthEXT < 0.0) discard;
-    if (gl_FragDepthEXT > 1.0) discard;
+    gl_FragDepthEXT = fragmentDepth;
 
-    float fragmentDepth = gl_FragDepthEXT;
+    vec3 vModelPosition = (uInvView * vec4(vViewPosition, 1.0)).xyz;
     #include assign_material_color
 
     #if defined(dRenderVariant_pick)
diff --git a/src/mol-gl/shader/spheres.frag.ts b/src/mol-gl/shader/spheres.frag.ts
index cf8f8a990be9b9ed8a697021bba245cf294ab834..9397260a5c8606cd8f364ed15d28ab0c20a001ba 100644
--- a/src/mol-gl/shader/spheres.frag.ts
+++ b/src/mol-gl/shader/spheres.frag.ts
@@ -70,17 +70,17 @@ void main(void){
     }
 
     vec3 vViewPosition = cameraPos;
-    gl_FragDepthEXT = calcDepth(vViewPosition);
-    if (!flag && gl_FragDepthEXT >= 0.0) {
-        gl_FragDepthEXT = 0.0 + (0.0000001 / vRadius);
+    float fragmentDepth = calcDepth(vViewPosition);
+    if (!flag && fragmentDepth >= 0.0) {
+        fragmentDepth = 0.0 + (0.0000001 / vRadius);
     }
 
-    vec3 vModelPosition = (uInvView * vec4(vViewPosition, 1.0)).xyz;
+    if (fragmentDepth < 0.0) discard;
+    if (fragmentDepth > 1.0) discard;
 
-    if (gl_FragDepthEXT < 0.0) discard;
-    if (gl_FragDepthEXT > 1.0) discard;
+    gl_FragDepthEXT = fragmentDepth;
 
-    float fragmentDepth = gl_FragDepthEXT;
+    vec3 vModelPosition = (uInvView * vec4(vViewPosition, 1.0)).xyz;
     #include assign_material_color
 
     #if defined(dRenderVariant_pick)
diff --git a/src/mol-gl/webgl/context.ts b/src/mol-gl/webgl/context.ts
index c3cd962a4f42716371db39688dcd4c66cd46ac22..f8d399ae8c71206870a3429fea2b790538f4d96a 100644
--- a/src/mol-gl/webgl/context.ts
+++ b/src/mol-gl/webgl/context.ts
@@ -142,12 +142,12 @@ export function readPixels(gl: GLRenderingContext, x: number, y: number, width:
     if (isDebugMode) checkError(gl);
 }
 
-function getDrawingBufferPixelData(gl: GLRenderingContext) {
+function getDrawingBufferPixelData(gl: GLRenderingContext, state: WebGLState) {
     const w = gl.drawingBufferWidth;
     const h = gl.drawingBufferHeight;
     const buffer = new Uint8Array(w * h * 4);
     unbindFramebuffer(gl);
-    gl.viewport(0, 0, w, h);
+    state.viewport(0, 0, w, h);
     readPixels(gl, 0, 0, w, h, buffer);
     return PixelData.flipY(PixelData.create(buffer, w, h));
 }
@@ -164,6 +164,7 @@ function createStats() {
             renderbuffer: 0,
             shader: 0,
             texture: 0,
+            cubeTexture: 0,
             vertexArray: 0,
         },
 
@@ -345,15 +346,15 @@ export function createContext(gl: GLRenderingContext, props: Partial<{ pixelScal
         readPixelsAsync,
         waitForGpuCommandsComplete: () => waitForGpuCommandsComplete(gl),
         waitForGpuCommandsCompleteSync: () => waitForGpuCommandsCompleteSync(gl),
-        getDrawingBufferPixelData: () => getDrawingBufferPixelData(gl),
+        getDrawingBufferPixelData: () => getDrawingBufferPixelData(gl, state),
         clear: (red: number, green: number, blue: number, alpha: number) => {
             unbindFramebuffer(gl);
             state.enable(gl.SCISSOR_TEST);
             state.depthMask(true);
             state.colorMask(true, true, true, true);
             state.clearColor(red, green, blue, alpha);
-            gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
-            gl.scissor(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
+            state.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
+            state.scissor(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
             gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
         },
 
diff --git a/src/mol-gl/webgl/render-item.ts b/src/mol-gl/webgl/render-item.ts
index e05dced716b9d365cf241e6e0a3191b4f4a19d3c..83f044f37abc7f117685b24792e4a608f2455b85 100644
--- a/src/mol-gl/webgl/render-item.ts
+++ b/src/mol-gl/webgl/render-item.ts
@@ -150,8 +150,8 @@ export function createRenderItem<T extends string>(ctx: WebGLContext, drawMode:
         vertexArrays[k] = vertexArrayObject ? resources.vertexArray(programs[k], attributeBuffers, elementsBuffer) : null;
     }
 
-    let drawCount = values.drawCount.ref.value;
-    let instanceCount = values.instanceCount.ref.value;
+    let drawCount: number = values.drawCount.ref.value;
+    let instanceCount: number = values.instanceCount.ref.value;
 
     stats.drawCount += drawCount;
     stats.instanceCount += instanceCount;
@@ -168,7 +168,7 @@ export function createRenderItem<T extends string>(ctx: WebGLContext, drawMode:
         getProgram: (variant: T) => programs[variant],
 
         render: (variant: T, sharedTexturesCount: number) => {
-            if (drawCount === 0 || instanceCount === 0 || ctx.isContextLost) return;
+            if (drawCount === 0 || instanceCount === 0) return;
             const program = programs[variant];
             if (program.id === currentProgramId && state.currentRenderItemId === id) {
                 program.setUniforms(uniformValueEntries);
diff --git a/src/mol-gl/webgl/resources.ts b/src/mol-gl/webgl/resources.ts
index 4dd175cc7788bfdffd80614b8a56add7d2f7a336..3e2e2d370bfc771499cf8b5b7641f5ab25b2ce52 100644
--- a/src/mol-gl/webgl/resources.ts
+++ b/src/mol-gl/webgl/resources.ts
@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2020-2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2020-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
  */
@@ -17,7 +17,7 @@ import { hashString, hashFnv32a } from '../../mol-data/util';
 import { DefineValues, ShaderCode } from '../shader-code';
 import { RenderableSchema } from '../renderable/schema';
 import { createRenderbuffer, Renderbuffer, RenderbufferAttachment, RenderbufferFormat } from './renderbuffer';
-import { Texture, TextureKind, TextureFormat, TextureType, TextureFilter, createTexture } from './texture';
+import { Texture, TextureKind, TextureFormat, TextureType, TextureFilter, createTexture, CubeFaces, createCubeTexture } from './texture';
 import { VertexArray, createVertexArray } from './vertex-array';
 
 function defineValueHash(v: boolean | number | string): number {
@@ -59,6 +59,7 @@ export interface WebGLResources {
     renderbuffer: (format: RenderbufferFormat, attachment: RenderbufferAttachment, width: number, height: number) => Renderbuffer
     shader: (type: ShaderType, source: string) => Shader
     texture: (kind: TextureKind, format: TextureFormat, type: TextureType, filter: TextureFilter) => Texture,
+    cubeTexture: (faces: CubeFaces, mipaps: boolean, onload?: () => void) => Texture,
     vertexArray: (program: Program, attributeBuffers: AttributeBuffers, elementsBuffer?: ElementsBuffer) => VertexArray,
 
     getByteCounts: () => ByteCounts
@@ -76,6 +77,7 @@ export function createResources(gl: GLRenderingContext, state: WebGLState, stats
         renderbuffer: new Set<Resource>(),
         shader: new Set<Resource>(),
         texture: new Set<Resource>(),
+        cubeTexture: new Set<Resource>(),
         vertexArray: new Set<Resource>(),
     };
 
@@ -137,6 +139,9 @@ export function createResources(gl: GLRenderingContext, state: WebGLState, stats
         texture: (kind: TextureKind, format: TextureFormat, type: TextureType, filter: TextureFilter) => {
             return wrap('texture', createTexture(gl, extensions, kind, format, type, filter));
         },
+        cubeTexture: (faces: CubeFaces, mipmaps: boolean, onload?: () => void) => {
+            return wrap('cubeTexture', createCubeTexture(gl, faces, mipmaps, onload));
+        },
         vertexArray: (program: Program, attributeBuffers: AttributeBuffers, elementsBuffer?: ElementsBuffer) => {
             return wrap('vertexArray', createVertexArray(gl, extensions, program, attributeBuffers, elementsBuffer));
         },
@@ -146,6 +151,9 @@ export function createResources(gl: GLRenderingContext, state: WebGLState, stats
             sets.texture.forEach(r => {
                 texture += (r as Texture).getByteCount();
             });
+            sets.cubeTexture.forEach(r => {
+                texture += (r as Texture).getByteCount();
+            });
 
             let attribute = 0;
             sets.attribute.forEach(r => {
diff --git a/src/mol-gl/webgl/state.ts b/src/mol-gl/webgl/state.ts
index d84c91bc8fd48ed129b996d74dfdada51f7b958c..dc6184d7e894b8d274a4c111a1fc8355436b1e88 100644
--- a/src/mol-gl/webgl/state.ts
+++ b/src/mol-gl/webgl/state.ts
@@ -69,6 +69,9 @@ export type WebGLState = {
     clearVertexAttribsState: () => void
     disableUnusedVertexAttribs: () => void
 
+    viewport: (x: number, y: number, width: number, height: number) => void
+    scissor: (x: number, y: number, width: number, height: number) => void
+
     reset: () => void
 }
 
@@ -95,6 +98,9 @@ export function createState(gl: GLRenderingContext): WebGLState {
     let maxVertexAttribs = gl.getParameter(gl.MAX_VERTEX_ATTRIBS);
     const vertexAttribsState: number[] = [];
 
+    let currentViewport: [number, number, number, number] = gl.getParameter(gl.VIEWPORT);
+    let currentScissor: [number, number, number, number] = gl.getParameter(gl.SCISSOR_BOX);
+
     const clearVertexAttribsState = () => {
         for (let i = 0; i < maxVertexAttribs; ++i) {
             vertexAttribsState[i] = 0;
@@ -222,6 +228,26 @@ export function createState(gl: GLRenderingContext): WebGLState {
             }
         },
 
+        viewport: (x: number, y: number, width: number, height: number) => {
+            if (x !== currentViewport[0] || y !== currentViewport[1] || width !== currentViewport[2] || height !== currentViewport[3]) {
+                gl.viewport(x, y, width, height);
+                currentViewport[0] = x;
+                currentViewport[1] = y;
+                currentViewport[2] = width;
+                currentViewport[3] = height;
+            }
+        },
+
+        scissor: (x: number, y: number, width: number, height: number) => {
+            if (x !== currentScissor[0] || y !== currentScissor[1] || width !== currentScissor[2] || height !== currentScissor[3]) {
+                gl.scissor(x, y, width, height);
+                currentScissor[0] = x;
+                currentScissor[1] = y;
+                currentScissor[2] = width;
+                currentScissor[3] = height;
+            }
+        },
+
         reset: () => {
             enabledCapabilities = {};
 
@@ -247,6 +273,9 @@ export function createState(gl: GLRenderingContext): WebGLState {
             for (let i = 0; i < maxVertexAttribs; ++i) {
                 vertexAttribsState[i] = 0;
             }
+
+            currentViewport = gl.getParameter(gl.VIEWPORT);
+            currentScissor = gl.getParameter(gl.SCISSOR_BOX);
         }
     };
 }
\ No newline at end of file
diff --git a/src/mol-gl/webgl/texture.ts b/src/mol-gl/webgl/texture.ts
index 3966a268d5d2d96cc7abbc94c18013523b86e617..26d4a2f1d20b44b89a51bc821c552efda8131e7e 100644
--- a/src/mol-gl/webgl/texture.ts
+++ b/src/mol-gl/webgl/texture.ts
@@ -11,8 +11,9 @@ import { RenderableSchema } from '../renderable/schema';
 import { idFactory } from '../../mol-util/id-factory';
 import { Framebuffer } from './framebuffer';
 import { isWebGL2, GLRenderingContext } from './compat';
-import { ValueOf } from '../../mol-util/type-helpers';
+import { isPromiseLike, ValueOf } from '../../mol-util/type-helpers';
 import { WebGLExtensions } from './extensions';
+import { objectForEach } from '../../mol-util/object';
 
 const getNextTextureId = idFactory();
 
@@ -423,6 +424,123 @@ export function loadImageTexture(src: string, cell: ValueCell<Texture>, texture:
 
 //
 
+export type CubeSide = 'nx' | 'ny' | 'nz' | 'px' | 'py' | 'pz';
+
+export type CubeFaces = {
+    [k in CubeSide]: string | File | Promise<Blob>;
+}
+
+export function getCubeTarget(gl: GLRenderingContext, side: CubeSide): number {
+    switch (side) {
+        case 'nx': return gl.TEXTURE_CUBE_MAP_NEGATIVE_X;
+        case 'ny': return gl.TEXTURE_CUBE_MAP_NEGATIVE_Y;
+        case 'nz': return gl.TEXTURE_CUBE_MAP_NEGATIVE_Z;
+        case 'px': return gl.TEXTURE_CUBE_MAP_POSITIVE_X;
+        case 'py': return gl.TEXTURE_CUBE_MAP_POSITIVE_Y;
+        case 'pz': return gl.TEXTURE_CUBE_MAP_POSITIVE_Z;
+    }
+}
+
+export function createCubeTexture(gl: GLRenderingContext, faces: CubeFaces, mipmaps: boolean, onload?: (errored?: boolean) => void): Texture {
+    const target = gl.TEXTURE_CUBE_MAP;
+    const filter = gl.LINEAR;
+    const internalFormat = gl.RGBA;
+    const format = gl.RGBA;
+    const type = gl.UNSIGNED_BYTE;
+
+    let size = 0;
+
+    const texture = gl.createTexture();
+    gl.bindTexture(target, texture);
+
+    let loadedCount = 0;
+    objectForEach(faces, (source, side) => {
+        if (!source) return;
+
+        const level = 0;
+        const cubeTarget = getCubeTarget(gl, side as CubeSide);
+
+        const image = new Image();
+        if (source instanceof File) {
+            image.src = URL.createObjectURL(source);
+        } else if (isPromiseLike(source)) {
+            source.then(blob => {
+                image.src = URL.createObjectURL(blob);
+            });
+        } else {
+            image.src = source;
+        }
+        image.addEventListener('load', () => {
+            if (size === 0) size = image.width;
+
+            gl.texImage2D(cubeTarget, level, internalFormat, size, size, 0, format, type, null);
+            gl.pixelStorei(gl.UNPACK_ALIGNMENT, 4);
+            gl.pixelStorei(gl.UNPACK_COLORSPACE_CONVERSION_WEBGL, gl.NONE);
+            gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, 0);
+            gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
+            gl.bindTexture(target, texture);
+            gl.texImage2D(cubeTarget, level, internalFormat, format, type, image);
+
+            loadedCount += 1;
+            if (loadedCount === 6) {
+                if (!destroyed) {
+                    if (mipmaps) {
+                        gl.generateMipmap(target);
+                        gl.texParameteri(target, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
+                    } else {
+                        gl.texParameteri(target, gl.TEXTURE_MIN_FILTER, filter);
+                    }
+                    gl.texParameteri(target, gl.TEXTURE_MAG_FILTER, filter);
+                }
+                onload?.(destroyed);
+            }
+        });
+        image.addEventListener('error', () => {
+            onload?.(true);
+        });
+    });
+
+    let destroyed = false;
+
+    return {
+        id: getNextTextureId(),
+        target,
+        format,
+        internalFormat,
+        type,
+        filter,
+
+        getWidth: () => size,
+        getHeight: () => size,
+        getDepth: () => 0,
+        getByteCount: () => {
+            return getByteCount('rgba', 'ubyte', size, size, 0) * 6 * (mipmaps ? 2 : 1);
+        },
+
+        define: () => {},
+        load: () => {},
+        bind: (id: TextureId) => {
+            gl.activeTexture(gl.TEXTURE0 + id);
+            gl.bindTexture(target, texture);
+        },
+        unbind: (id: TextureId) => {
+            gl.activeTexture(gl.TEXTURE0 + id);
+            gl.bindTexture(target, null);
+        },
+        attachFramebuffer: () => {},
+        detachFramebuffer: () => {},
+
+        reset: () => {},
+        destroy: () => {
+            if (destroyed) return;
+            gl.deleteTexture(texture);
+            destroyed = true;
+        },
+    };
+}
+
+//
+
 export function createNullTexture(gl?: GLRenderingContext): Texture {
     const target = gl?.TEXTURE_2D ?? 3553;
     return {
diff --git a/src/mol-io/reader/cif/schema/bird.ts b/src/mol-io/reader/cif/schema/bird.ts
index 5d88c181affb0c14c762e23046174d3a1078b3ae..801063c85d8e96534f3956fe089442d381329d58 100644
--- a/src/mol-io/reader/cif/schema/bird.ts
+++ b/src/mol-io/reader/cif/schema/bird.ts
@@ -1,7 +1,7 @@
 /**
  * Copyright (c) 2017-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
- * Code-generated 'BIRD' schema file. Dictionary versions: mmCIF 5.359, IHM 1.17, MA 1.4.1.
+ * Code-generated 'BIRD' schema file. Dictionary versions: mmCIF 5.360, IHM 1.17, MA 1.4.2.
  *
  * @author molstar/ciftools package
  */
diff --git a/src/mol-io/reader/cif/schema/ccd.ts b/src/mol-io/reader/cif/schema/ccd.ts
index 86ea246065cc2bb1cbdf248cb1f815311648be8f..bffecfa236872ef43c9780ec78a5a85e415ccba1 100644
--- a/src/mol-io/reader/cif/schema/ccd.ts
+++ b/src/mol-io/reader/cif/schema/ccd.ts
@@ -1,7 +1,7 @@
 /**
  * Copyright (c) 2017-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
- * Code-generated 'CCD' schema file. Dictionary versions: mmCIF 5.359, IHM 1.17, MA 1.4.1.
+ * Code-generated 'CCD' schema file. Dictionary versions: mmCIF 5.360, IHM 1.17, MA 1.4.2.
  *
  * @author molstar/ciftools package
  */
diff --git a/src/mol-io/reader/cif/schema/mmcif.ts b/src/mol-io/reader/cif/schema/mmcif.ts
index 51d060ddc0fd8cf5076fdcaac37712256978edbd..f8a84b566ad2245df621a7e973e6086ead1a9bd6 100644
--- a/src/mol-io/reader/cif/schema/mmcif.ts
+++ b/src/mol-io/reader/cif/schema/mmcif.ts
@@ -1,7 +1,7 @@
 /**
  * Copyright (c) 2017-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
- * Code-generated 'mmCIF' schema file. Dictionary versions: mmCIF 5.359, IHM 1.17, MA 1.4.1.
+ * Code-generated 'mmCIF' schema file. Dictionary versions: mmCIF 5.360, IHM 1.17, MA 1.4.2.
  *
  * @author molstar/ciftools package
  */
diff --git a/src/mol-math/geometry/gaussian-density/gpu.ts b/src/mol-math/geometry/gaussian-density/gpu.ts
index 97c5f0c861cd460b0ecf629bc1722aca4b8475a9..05a26567d0017bbabde11f392d26bca3ccb07afe 100644
--- a/src/mol-math/geometry/gaussian-density/gpu.ts
+++ b/src/mol-math/geometry/gaussian-density/gpu.ts
@@ -166,8 +166,8 @@ function calcGaussianDensityTexture2d(webgl: WebGLContext, position: PositionDat
         state.currentRenderItemId = -1;
         fbTex.attachFramebuffer(framebuffer, 0);
         if (clear) {
-            gl.viewport(0, 0, width, height);
-            gl.scissor(0, 0, width, height);
+            state.viewport(0, 0, width, height);
+            state.scissor(0, 0, width, height);
             gl.clear(gl.COLOR_BUFFER_BIT);
         }
         ValueCell.update(uCurrentY, 0);
@@ -184,8 +184,8 @@ function calcGaussianDensityTexture2d(webgl: WebGLContext, position: PositionDat
             // console.log({ i, currX, currY });
             ValueCell.update(uCurrentX, currX);
             ValueCell.update(uCurrentSlice, i);
-            gl.viewport(currX, currY, dx, dy);
-            gl.scissor(currX, currY, dx, dy);
+            state.viewport(currX, currY, dx, dy);
+            state.scissor(currX, currY, dx, dy);
             renderable.render();
             ++currCol;
             currX += dx;
@@ -232,8 +232,8 @@ function calcGaussianDensityTexture3d(webgl: WebGLContext, position: PositionDat
     const framebuffer = getFramebuffer(webgl);
     framebuffer.bind();
     setRenderingDefaults(webgl);
-    gl.viewport(0, 0, dx, dy);
-    gl.scissor(0, 0, dx, dy);
+    state.viewport(0, 0, dx, dy);
+    state.scissor(0, 0, dx, dy);
 
     if (!texture) texture = colorBufferHalfFloat && textureHalfFloat
         ? resources.texture('volume-float16', 'rgba', 'fp16', 'linear')
diff --git a/src/mol-math/geometry/primitives/box3d.ts b/src/mol-math/geometry/primitives/box3d.ts
index d701ae18085f5b45feb9f29cb7cdd5682e9104f9..fff4923615107fddbbe9be77febc27b070182889 100644
--- a/src/mol-math/geometry/primitives/box3d.ts
+++ b/src/mol-math/geometry/primitives/box3d.ts
@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2018-2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2018-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author David Sehnal <david.sehnal@gmail.com>
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
@@ -124,11 +124,19 @@ namespace Box3D {
     }
 
     export function containsVec3(box: Box3D, v: Vec3) {
-        return (
+        return !(
             v[0] < box.min[0] || v[0] > box.max[0] ||
             v[1] < box.min[1] || v[1] > box.max[1] ||
             v[2] < box.min[2] || v[2] > box.max[2]
-        ) ? false : true;
+        );
+    }
+
+    export function overlaps(a: Box3D, b: Box3D) {
+        return !(
+            a.max[0] < b.min[0] || a.min[0] > b.max[0] ||
+            a.max[1] < b.min[1] || a.min[1] > b.max[1] ||
+            a.max[2] < b.min[2] || a.min[2] > b.max[2]
+        );
     }
 }
 
diff --git a/src/mol-math/linear-algebra/3d/vec3.ts b/src/mol-math/linear-algebra/3d/vec3.ts
index 5c5e39f21ed821b16da7cbd614dcf38943468358..d55b4ee8a79c6abccef76f75969fffe2813ffe83 100644
--- a/src/mol-math/linear-algebra/3d/vec3.ts
+++ b/src/mol-math/linear-algebra/3d/vec3.ts
@@ -35,7 +35,7 @@ function Vec3() {
 
 namespace Vec3 {
     export function zero(): Vec3 {
-        const out = [0.1, 0.0, 0.0];
+        const out = [0.1, 0.0, 0.0]; // ensure backing array of type double
         out[0] = 0;
         return out as any;
     }
diff --git a/src/mol-model-formats/structure/mol.ts b/src/mol-model-formats/structure/mol.ts
index 942f24a597cd0c8bbb18c564d571536e9310f49f..f32849f738b1bb31f4a9aca641bbe2a55adf1e91 100644
--- a/src/mol-model-formats/structure/mol.ts
+++ b/src/mol-model-formats/structure/mol.ts
@@ -80,7 +80,10 @@ export async function getMolModels(mol: MolFile, format: ModelFormat<any> | unde
         const indexA = Column.ofIntArray(Column.mapToArray(bonds.atomIdxA, x => x - 1, Int32Array));
         const indexB = Column.ofIntArray(Column.mapToArray(bonds.atomIdxB, x => x - 1, Int32Array));
         const order = Column.asArrayColumn(bonds.order, Int32Array);
-        const pairBonds = IndexPairBonds.fromData({ pairs: { indexA, indexB, order }, count: atoms.count });
+        const pairBonds = IndexPairBonds.fromData(
+            { pairs: { indexA, indexB, order }, count: atoms.count },
+            { maxDistance: Infinity }
+        );
         IndexPairBonds.Provider.set(models.representative, pairBonds);
     }
 
diff --git a/src/mol-model-formats/structure/mol2.ts b/src/mol-model-formats/structure/mol2.ts
index ac8b4e75c1119a06afa11a91d18709f888129418..19723a6fd1987e77bb382bafb2621f98e95bfbeb 100644
--- a/src/mol-model-formats/structure/mol2.ts
+++ b/src/mol-model-formats/structure/mol2.ts
@@ -113,7 +113,10 @@ async function getModels(mol2: Mol2File, ctx: RuntimeContext) {
                         return BondType.Flag.Covalent;
                 }
             }, Int8Array));
-            const pairBonds = IndexPairBonds.fromData({ pairs: { key, indexA, indexB, order, flag }, count: atoms.count });
+            const pairBonds = IndexPairBonds.fromData(
+                { pairs: { key, indexA, indexB, order, flag }, count: atoms.count },
+                { maxDistance: crysin ? -1 : Infinity }
+            );
 
             const first = _models.representative;
             IndexPairBonds.Provider.set(first, pairBonds);
diff --git a/src/mol-model-props/common/custom-element-property.ts b/src/mol-model-props/common/custom-element-property.ts
index 8f60da9d85a008d47d0120c60fdce7ceff08b03b..47580e1985a8d146db25eb5c2111234d59c6799e 100644
--- a/src/mol-model-props/common/custom-element-property.ts
+++ b/src/mol-model-props/common/custom-element-property.ts
@@ -106,7 +106,7 @@ namespace CustomElementProperty {
             factory: Coloring,
             getParams: () => ({}),
             defaultValues: {},
-            isApplicable: (ctx: ThemeDataContext) => !!ctx.structure && !!modelProperty.get(ctx.structure.models[0]).value,
+            isApplicable: (ctx: ThemeDataContext) => !!ctx.structure,
             ensureCustomProperties: {
                 attach: (ctx: CustomProperty.Context, data: ThemeDataContext) => data.structure ? modelProperty.attach(ctx, data.structure.models[0], void 0, true) : Promise.resolve(),
                 detach: (data: ThemeDataContext) => data.structure && data.structure.models[0].customProperties.reference(modelProperty.descriptor, false)
diff --git a/src/mol-model-props/computed/representations/interactions-inter-unit-cylinder.ts b/src/mol-model-props/computed/representations/interactions-inter-unit-cylinder.ts
index d07ee17b564f5f7cacac9fc97312946db51eecdf..9a0d9164212b5f844665d350b75a970d7621513c 100644
--- a/src/mol-model-props/computed/representations/interactions-inter-unit-cylinder.ts
+++ b/src/mol-model-props/computed/representations/interactions-inter-unit-cylinder.ts
@@ -22,6 +22,8 @@ import { LocationIterator } from '../../../mol-geo/util/location-iterator';
 import { InteractionFlag } from '../interactions/common';
 import { Unit } from '../../../mol-model/structure/structure';
 import { Sphere3D } from '../../../mol-math/geometry';
+import { assertUnreachable } from '../../../mol-util/type-helpers';
+import { InteractionsSharedParams } from './shared';
 
 function createInterUnitInteractionCylinderMesh(ctx: VisualContext, structure: Structure, theme: Theme, props: PD.Values<InteractionsInterUnitParams>, mesh?: Mesh) {
     if (!structure.hasAtomic) return Mesh.createEmpty(mesh);
@@ -31,7 +33,7 @@ function createInterUnitInteractionCylinderMesh(ctx: VisualContext, structure: S
     const { contacts, unitsFeatures } = interactions;
 
     const { edgeCount, edges } = contacts;
-    const { sizeFactor } = props;
+    const { sizeFactor, parentDisplay } = props;
 
     if (!edgeCount) return Mesh.createEmpty(mesh);
 
@@ -70,14 +72,48 @@ function createInterUnitInteractionCylinderMesh(ctx: VisualContext, structure: S
 
             if (child) {
                 const b = edges[edgeIndex];
-                const childUnitA = child.unitMap.get(b.unitA);
-                if (!childUnitA) return true;
-
-                const unitA = structure.unitMap.get(b.unitA);
-                const { offsets, members } = unitsFeatures.get(b.unitA);
-                for (let i = offsets[b.indexA], il = offsets[b.indexA + 1]; i < il; ++i) {
-                    const eA = unitA.elements[members[i]];
-                    if (!SortedArray.has(childUnitA.elements, eA)) return true;
+
+                if (parentDisplay === 'stub') {
+                    const childUnitA = child.unitMap.get(b.unitA);
+                    if (!childUnitA) return true;
+
+                    const unitA = structure.unitMap.get(b.unitA);
+                    const { offsets, members } = unitsFeatures.get(b.unitA);
+                    for (let i = offsets[b.indexA], il = offsets[b.indexA + 1]; i < il; ++i) {
+                        const eA = unitA.elements[members[i]];
+                        if (!SortedArray.has(childUnitA.elements, eA)) return true;
+                    }
+                } else if (parentDisplay === 'full' || parentDisplay === 'between') {
+                    let flagA = false;
+                    let flagB = false;
+
+                    const childUnitA = child.unitMap.get(b.unitA);
+                    if (!childUnitA) {
+                        flagA = true;
+                    } else {
+                        const unitA = structure.unitMap.get(b.unitA);
+                        const { offsets, members } = unitsFeatures.get(b.unitA);
+                        for (let i = offsets[b.indexA], il = offsets[b.indexA + 1]; i < il; ++i) {
+                            const eA = unitA.elements[members[i]];
+                            if (!SortedArray.has(childUnitA.elements, eA)) flagA = true;
+                        }
+                    }
+
+                    const childUnitB = child.unitMap.get(b.unitB);
+                    if (!childUnitB) {
+                        flagB = true;
+                    } else {
+                        const unitB = structure.unitMap.get(b.unitB);
+                        const { offsets, members } = unitsFeatures.get(b.unitB);
+                        for (let i = offsets[b.indexB], il = offsets[b.indexB + 1]; i < il; ++i) {
+                            const eB = unitB.elements[members[i]];
+                            if (!SortedArray.has(childUnitB.elements, eB)) flagB = true;
+                        }
+                    }
+
+                    return parentDisplay === 'full' ? flagA && flagB : flagA === flagB;
+                } else {
+                    assertUnreachable(parentDisplay);
                 }
             }
 
@@ -101,10 +137,7 @@ function createInterUnitInteractionCylinderMesh(ctx: VisualContext, structure: S
 export const InteractionsInterUnitParams = {
     ...ComplexMeshParams,
     ...LinkCylinderParams,
-    sizeFactor: PD.Numeric(0.3, { min: 0, max: 10, step: 0.01 }),
-    dashCount: PD.Numeric(6, { min: 2, max: 10, step: 2 }),
-    dashScale: PD.Numeric(0.4, { min: 0, max: 2, step: 0.1 }),
-    includeParent: PD.Boolean(false),
+    ...InteractionsSharedParams,
 };
 export type InteractionsInterUnitParams = typeof InteractionsInterUnitParams
 
@@ -121,7 +154,8 @@ export function InteractionsInterUnitVisual(materialId: number): ComplexVisual<I
                 newProps.dashCount !== currentProps.dashCount ||
                 newProps.dashScale !== currentProps.dashScale ||
                 newProps.dashCap !== currentProps.dashCap ||
-                newProps.radialSegments !== currentProps.radialSegments
+                newProps.radialSegments !== currentProps.radialSegments ||
+                newProps.parentDisplay !== currentProps.parentDisplay
             );
 
             const interactionsHash = InteractionsProvider.get(newStructure).version;
diff --git a/src/mol-model-props/computed/representations/interactions-intra-unit-cylinder.ts b/src/mol-model-props/computed/representations/interactions-intra-unit-cylinder.ts
index de3675163db17e5eb355e8c6a9f9c6e9ffee9863..e0289f7b75aeffbf37ba01dd2ff02a6194b75172 100644
--- a/src/mol-model-props/computed/representations/interactions-intra-unit-cylinder.ts
+++ b/src/mol-model-props/computed/representations/interactions-intra-unit-cylinder.ts
@@ -22,6 +22,8 @@ import { Interactions } from '../interactions/interactions';
 import { InteractionFlag } from '../interactions/common';
 import { Sphere3D } from '../../../mol-math/geometry';
 import { StructureGroup } from '../../../mol-repr/structure/visual/util/common';
+import { assertUnreachable } from '../../../mol-util/type-helpers';
+import { InteractionsSharedParams } from './shared';
 
 async function createIntraUnitInteractionsCylinderMesh(ctx: VisualContext, unit: Unit, structure: Structure, theme: Theme, props: PD.Values<InteractionsIntraUnitParams>, mesh?: Mesh) {
     if (!Unit.isAtomic(unit)) return Mesh.createEmpty(mesh);
@@ -38,7 +40,7 @@ async function createIntraUnitInteractionsCylinderMesh(ctx: VisualContext, unit:
 
     const { x, y, z, members, offsets } = features;
     const { edgeCount, a, b, edgeProps: { flag } } = contacts;
-    const { sizeFactor } = props;
+    const { sizeFactor, parentDisplay } = props;
 
     if (!edgeCount) return Mesh.createEmpty(mesh);
 
@@ -60,10 +62,31 @@ async function createIntraUnitInteractionsCylinderMesh(ctx: VisualContext, unit:
             if (flag[edgeIndex] === InteractionFlag.Filtered) return true;
 
             if (childUnit) {
-                const f = a[edgeIndex];
-                for (let i = offsets[f], jl = offsets[f + 1]; i < jl; ++i) {
-                    const e = unit.elements[members[offsets[i]]];
-                    if (!SortedArray.has(childUnit.elements, e)) return true;
+                if (parentDisplay === 'stub') {
+                    const f = a[edgeIndex];
+                    for (let i = offsets[f], il = offsets[f + 1]; i < il; ++i) {
+                        const e = unit.elements[members[offsets[i]]];
+                        if (!SortedArray.has(childUnit.elements, e)) return true;
+                    }
+                } else if (parentDisplay === 'full' || parentDisplay === 'between') {
+                    let flagA = false;
+                    let flagB = false;
+
+                    const fA = a[edgeIndex];
+                    for (let i = offsets[fA], il = offsets[fA + 1]; i < il; ++i) {
+                        const eA = unit.elements[members[offsets[i]]];
+                        if (!SortedArray.has(childUnit.elements, eA)) flagA = true;
+                    }
+
+                    const fB = b[edgeIndex];
+                    for (let i = offsets[fB], il = offsets[fB + 1]; i < il; ++i) {
+                        const eB = unit.elements[members[offsets[i]]];
+                        if (!SortedArray.has(childUnit.elements, eB)) flagB = true;
+                    }
+
+                    return parentDisplay === 'full' ? flagA && flagB : flagA === flagB;
+                } else {
+                    assertUnreachable(parentDisplay);
                 }
             }
 
@@ -86,10 +109,7 @@ async function createIntraUnitInteractionsCylinderMesh(ctx: VisualContext, unit:
 export const InteractionsIntraUnitParams = {
     ...UnitsMeshParams,
     ...LinkCylinderParams,
-    sizeFactor: PD.Numeric(0.3, { min: 0, max: 10, step: 0.01 }),
-    dashCount: PD.Numeric(6, { min: 2, max: 10, step: 2 }),
-    dashScale: PD.Numeric(0.4, { min: 0, max: 2, step: 0.1 }),
-    includeParent: PD.Boolean(false),
+    ...InteractionsSharedParams,
 };
 export type InteractionsIntraUnitParams = typeof InteractionsIntraUnitParams
 
@@ -106,7 +126,8 @@ export function InteractionsIntraUnitVisual(materialId: number): UnitsVisual<Int
                 newProps.dashCount !== currentProps.dashCount ||
                 newProps.dashScale !== currentProps.dashScale ||
                 newProps.dashCap !== currentProps.dashCap ||
-                newProps.radialSegments !== currentProps.radialSegments
+                newProps.radialSegments !== currentProps.radialSegments ||
+                newProps.parentDisplay !== currentProps.parentDisplay
             );
 
             const interactionsHash = InteractionsProvider.get(newStructureGroup.structure).version;
diff --git a/src/mol-model-props/computed/representations/shared.ts b/src/mol-model-props/computed/representations/shared.ts
new file mode 100644
index 0000000000000000000000000000000000000000..61d70bf0745baf00cd9de2ae558a3e19116e54c1
--- /dev/null
+++ b/src/mol-model-props/computed/representations/shared.ts
@@ -0,0 +1,16 @@
+/**
+ * Copyright (c) 2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Alexander Rose <alexander.rose@weirdbyte.de>
+ */
+
+import { ParamDefinition as PD } from '../../../mol-util/param-definition';
+
+export const InteractionsSharedParams = {
+    sizeFactor: PD.Numeric(0.3, { min: 0, max: 10, step: 0.01 }),
+    dashCount: PD.Numeric(6, { min: 2, max: 10, step: 2 }),
+    dashScale: PD.Numeric(0.4, { min: 0, max: 2, step: 0.1 }),
+    includeParent: PD.Boolean(false),
+    parentDisplay: PD.Select('stub', PD.arrayToOptions(['stub', 'full', 'between'] as const), { description: 'Only has an effect when "includeParent" is enabled. "Stub" shows just the child side of interactions to the parent. "Full" shows both sides of interactions to the parent. "Between" shows only interactions to the parent.' }),
+};
+export type InteractionsSharedParams = typeof InteractionsSharedParams
diff --git a/src/mol-model/structure/structure/carbohydrates/constants.ts b/src/mol-model/structure/structure/carbohydrates/constants.ts
index bc9e49e728e386db75c92dfd118ee3e875d3de9d..aeb5fd82a1c2515becfcf439750f9fb7ccb35d8d 100644
--- a/src/mol-model/structure/structure/carbohydrates/constants.ts
+++ b/src/mol-model/structure/structure/carbohydrates/constants.ts
@@ -386,16 +386,6 @@ const DefaultSaccharideCompIdMap = (function () {
                 map.set(charmm[j], saccharide);
             }
         }
-
-        const glycam = GlycamSaccharideNames[saccharide.abbr];
-        if (glycam) {
-            for (let j = 0, jl = glycam.length; j < jl; ++j) {
-                // On collision, use PDB name as default.
-                if (!map.has(glycam[j])) {
-                    map.set(glycam[j], saccharide);
-                }
-            }
-        }
     }
     SaccharideNames.forEach(name => {
         if (!map.has(name)) map.set(name, UnknownSaccharideComponent);
diff --git a/src/mol-model/structure/structure/structure.ts b/src/mol-model/structure/structure/structure.ts
index 81f84f447fa5c17191641c2d8c3b81594bcdc08d..3c2dfdc6322d5895bf82dbd5c6689c78b58d4bae 100644
--- a/src/mol-model/structure/structure/structure.ts
+++ b/src/mol-model/structure/structure/structure.ts
@@ -23,7 +23,7 @@ import { Carbohydrates } from './carbohydrates/data';
 import { computeCarbohydrates } from './carbohydrates/compute';
 import { Vec3, Mat4 } from '../../../mol-math/linear-algebra';
 import { idFactory } from '../../../mol-util/id-factory';
-import { GridLookup3D } from '../../../mol-math/geometry';
+import { Box3D, GridLookup3D } from '../../../mol-math/geometry';
 import { UUID } from '../../../mol-util';
 import { CustomProperties } from '../../custom-property';
 import { AtomicHierarchy } from '../model/properties/atomic';
@@ -43,6 +43,8 @@ type State = {
     lookup3d?: StructureLookup3D,
     interUnitBonds?: InterUnitBonds,
     dynamicBonds: boolean,
+    interBondsValidUnit?: (unit: Unit) => boolean,
+    interBondsValidUnitPair?: (structure: Structure, unitA: Unit, unitB: Unit) => boolean,
     unitSymmetryGroups?: ReadonlyArray<Unit.SymmetryGroup>,
     unitSymmetryGroupsIndexMap?: IntMap<number>,
     unitsSortedByVolume?: ReadonlyArray<Unit>;
@@ -241,6 +243,8 @@ class Structure {
             this.state.interUnitBonds = computeInterUnitBonds(this, {
                 ignoreWater: !this.dynamicBonds,
                 ignoreIon: !this.dynamicBonds,
+                validUnit: this.state.interBondsValidUnit,
+                validUnitPair: this.state.interBondsValidUnitPair,
             });
         }
         return this.state.interUnitBonds;
@@ -250,6 +254,14 @@ class Structure {
         return this.state.dynamicBonds;
     }
 
+    get interBondsValidUnit() {
+        return this.state.interBondsValidUnit;
+    }
+
+    get interBondsValidUnitPair() {
+        return this.state.interBondsValidUnitPair;
+    }
+
     get unitSymmetryGroups(): ReadonlyArray<Unit.SymmetryGroup> {
         if (this.state.unitSymmetryGroups) return this.state.unitSymmetryGroups;
         this.state.unitSymmetryGroups = StructureSymmetry.computeTransformGroups(this);
@@ -380,7 +392,12 @@ class Structure {
             parent: parent?.remapModel(m),
             label: this.label,
             interUnitBonds: dynamicBonds ? undefined : interUnitBonds,
-            dynamicBonds
+            dynamicBonds,
+            interBondsValidUnit: this.state.interBondsValidUnit,
+            interBondsValidUnitPair: this.state.interBondsValidUnitPair,
+            coordinateSystem: this.state.coordinateSystem,
+            masterModel: this.state.masterModel,
+            representativeModel: this.state.representativeModel,
         });
     }
 
@@ -428,7 +445,6 @@ class Structure {
 
 function cmpUnits(units: ArrayLike<Unit>, i: number, j: number) {
     return units[i].id - units[j].id;
-
 }
 
 function getModels(s: Structure) {
@@ -634,6 +650,8 @@ namespace Structure {
          * Also enables calculation of inter-unit bonds in water molecules.
          */
         dynamicBonds?: boolean,
+        interBondsValidUnit?: (unit: Unit) => boolean,
+        interBondsValidUnitPair?: (structure: Structure, unitA: Unit, unitB: Unit) => boolean,
         coordinateSystem?: SymmetryOperator
         label?: string
         /** Master model for structures of a protein model and multiple ligand models */
@@ -722,6 +740,12 @@ namespace Structure {
         if (props.parent) state.parent = props.parent.parent || props.parent;
         if (props.interUnitBonds) state.interUnitBonds = props.interUnitBonds;
 
+        if (props.interBondsValidUnit) state.interBondsValidUnit = props.interBondsValidUnit;
+        else if (props.parent) state.interBondsValidUnit = props.parent.interBondsValidUnit;
+
+        if (props.interBondsValidUnitPair) state.interBondsValidUnitPair = props.interBondsValidUnitPair;
+        else if (props.parent) state.interBondsValidUnitPair = props.parent.interBondsValidUnitPair;
+
         if (props.dynamicBonds) state.dynamicBonds = props.dynamicBonds;
         else if (props.parent) state.dynamicBonds = props.parent.dynamicBonds;
 
@@ -1180,7 +1204,7 @@ namespace Structure {
 
     /**
      * Iterate over all unit pairs of a structure and invokes callback for valid units
-     * and unit pairs if within a max distance.
+     * and unit pairs if their boundaries are within a max distance.
      */
     export function eachUnitPair(structure: Structure, callback: (unitA: Unit, unitB: Unit) => void, props: EachUnitPairProps) {
         const { maxRadius, validUnit, validUnitPair } = props;
@@ -1188,15 +1212,19 @@ namespace Structure {
 
         const lookup = structure.lookup3d;
         const imageCenter = Vec3();
+        const bbox = Box3D();
+        const rvec = Vec3.create(maxRadius, maxRadius, maxRadius);
 
         for (const unit of structure.units) {
             if (!validUnit(unit)) continue;
 
             const bs = unit.boundary.sphere;
+            Box3D.expand(bbox, unit.boundary.box, rvec);
             Vec3.transformMat4(imageCenter, bs.center, unit.conformation.operator.matrix);
             const closeUnits = lookup.findUnitIndices(imageCenter[0], imageCenter[1], imageCenter[2], bs.radius + maxRadius);
             for (let i = 0; i < closeUnits.count; i++) {
                 const other = structure.units[closeUnits.indices[i]];
+                if (!Box3D.overlaps(bbox, other.boundary.box)) continue;
                 if (!validUnit(other) || unit.id >= other.id || !validUnitPair(unit, other)) continue;
 
                 if (other.elements.length >= unit.elements.length) callback(unit, other);
diff --git a/src/mol-model/structure/structure/unit/bonds/inter-compute.ts b/src/mol-model/structure/structure/unit/bonds/inter-compute.ts
index 535a6d09bca596e00119faffd2cf2fcefafd9344..ba0327c3fc2ffaecbc25b4abe2ed8b434a40373d 100644
--- a/src/mol-model/structure/structure/unit/bonds/inter-compute.ts
+++ b/src/mol-model/structure/structure/unit/bonds/inter-compute.ts
@@ -21,12 +21,18 @@ import { StructConn } from '../../../../../mol-model-formats/structure/property/
 import { equalEps } from '../../../../../mol-math/linear-algebra/3d/common';
 import { Model } from '../../../model';
 
+// avoiding namespace lookup improved performance in Chrome (Aug 2020)
+const v3distance = Vec3.distance;
+const v3set = Vec3.set;
+const v3squaredDistance = Vec3.squaredDistance;
+const v3transformMat4 = Vec3.transformMat4;
+
 const tmpDistVecA = Vec3();
 const tmpDistVecB = Vec3();
 function getDistance(unitA: Unit.Atomic, indexA: ElementIndex, unitB: Unit.Atomic, indexB: ElementIndex) {
     unitA.conformation.position(indexA, tmpDistVecA);
     unitB.conformation.position(indexB, tmpDistVecB);
-    return Vec3.distance(tmpDistVecA, tmpDistVecB);
+    return v3distance(tmpDistVecA, tmpDistVecB);
 }
 
 const _imageTransform = Mat4();
@@ -68,22 +74,22 @@ function findPairBonds(unitA: Unit.Atomic, unitB: Unit.Atomic, props: BondComput
 
     for (let _aI = 0 as StructureElement.UnitIndex; _aI < atomCount; _aI++) {
         const aI = atomsA[_aI];
-        Vec3.set(_imageA, xA[aI], yA[aI], zA[aI]);
-        if (isNotIdentity) Vec3.transformMat4(_imageA, _imageA, imageTransform);
-        if (Vec3.squaredDistance(_imageA, bCenter) > testDistanceSq) continue;
+        v3set(_imageA, xA[aI], yA[aI], zA[aI]);
+        if (isNotIdentity) v3transformMat4(_imageA, _imageA, imageTransform);
+        if (v3squaredDistance(_imageA, bCenter) > testDistanceSq) continue;
 
         if (!props.forceCompute && indexPairs) {
             const { maxDistance } = indexPairs;
             const { offset, b, edgeProps: { order, distance, flag } } = indexPairs.bonds;
 
             const srcA = sourceIndex.value(aI);
+            const aeI = getElementIdx(type_symbolA.value(aI));
             for (let i = offset[srcA], il = offset[srcA + 1]; i < il; ++i) {
                 const bI = invertedIndex![b[i]];
 
                 const _bI = SortedArray.indexOf(unitB.elements, bI) as StructureElement.UnitIndex;
                 if (_bI < 0) continue;
 
-                const aeI = getElementIdx(type_symbolA.value(aI));
                 const beI = getElementIdx(type_symbolA.value(bI));
 
                 const d = distance[i];
@@ -191,6 +197,7 @@ function findPairBonds(unitA: Unit.Atomic, unitB: Unit.Atomic, props: BondComput
 }
 
 export interface InterBondComputationProps extends BondComputationProps {
+    validUnit: (unit: Unit) => boolean
     validUnitPair: (structure: Structure, unitA: Unit, unitB: Unit) => boolean
     ignoreWater: boolean
     ignoreIon: boolean
@@ -215,7 +222,7 @@ function findBonds(structure: Structure, props: InterBondComputationProps) {
         findPairBonds(unitA as Unit.Atomic, unitB as Unit.Atomic, props, builder);
     }, {
         maxRadius: props.maxRadius,
-        validUnit: (unit: Unit) => Unit.isAtomic(unit),
+        validUnit: (unit: Unit) => props.validUnit(unit),
         validUnitPair: (unitA: Unit, unitB: Unit) => props.validUnitPair(structure, unitA, unitB)
     });
 
@@ -226,6 +233,7 @@ function computeInterUnitBonds(structure: Structure, props?: Partial<InterBondCo
     const p = { ...DefaultInterBondComputationProps, ...props };
     return findBonds(structure, {
         ...p,
+        validUnit: (props && props.validUnit) || (u => Unit.isAtomic(u)),
         validUnitPair: (props && props.validUnitPair) || ((s, a, b) => {
             const mtA = a.model.atomicHierarchy.derived.residue.moleculeType;
             const mtB = b.model.atomicHierarchy.derived.residue.moleculeType;
diff --git a/src/mol-model/structure/structure/unit/bonds/intra-compute.ts b/src/mol-model/structure/structure/unit/bonds/intra-compute.ts
index 105347895b27c9080d7e5daeca079c975cbb6261..a4be859321bf19f44ea5c7902fff9d373e7565d8 100644
--- a/src/mol-model/structure/structure/unit/bonds/intra-compute.ts
+++ b/src/mol-model/structure/structure/unit/bonds/intra-compute.ts
@@ -21,6 +21,9 @@ import { ElementIndex } from '../../../model/indexing';
 import { equalEps } from '../../../../../mol-math/linear-algebra/3d/common';
 import { Model } from '../../../model/model';
 
+// avoiding namespace lookup improved performance in Chrome (Aug 2020)
+const v3distance = Vec3.distance;
+
 function getGraph(atomA: StructureElement.UnitIndex[], atomB: StructureElement.UnitIndex[], _order: number[], _flags: number[], atomCount: number, canRemap: boolean): IntraUnitBonds {
     const builder = new IntAdjacencyGraph.EdgeBuilder(atomCount, atomA, atomB);
     const flags = new Uint16Array(builder.slotCount);
@@ -39,7 +42,7 @@ const tmpDistVecB = Vec3();
 function getDistance(unit: Unit.Atomic, indexA: ElementIndex, indexB: ElementIndex) {
     unit.conformation.position(indexA, tmpDistVecA);
     unit.conformation.position(indexB, tmpDistVecB);
-    return Vec3.distance(tmpDistVecA, tmpDistVecB);
+    return v3distance(tmpDistVecA, tmpDistVecB);
 }
 
 const __structConnAdded = new Set<StructureElement.UnitIndex>();
diff --git a/src/mol-model/structure/structure/util/superposition-sifts-mapping.ts b/src/mol-model/structure/structure/util/superposition-sifts-mapping.ts
index 4abf51e51762533338aad2ce4fe2f73e41d63c52..48fec9d0bd6fa744c5a4e701b56d19a659cd39ed 100644
--- a/src/mol-model/structure/structure/util/superposition-sifts-mapping.ts
+++ b/src/mol-model/structure/structure/util/superposition-sifts-mapping.ts
@@ -8,7 +8,8 @@
 import { Segmentation } from '../../../../mol-data/int';
 import { MinimizeRmsd } from '../../../../mol-math/linear-algebra/3d/minimize-rmsd';
 import { SIFTSMapping } from '../../../../mol-model-props/sequence/sifts-mapping';
-import { ElementIndex } from '../../model/indexing';
+import { ElementIndex, ResidueIndex } from '../../model/indexing';
+import { StructureElement } from '../element';
 import { Structure } from '../structure';
 import { Unit } from '../unit';
 
@@ -24,11 +25,16 @@ export interface AlignmentResult {
     failedPairs: [number, number][]
 }
 
-export function alignAndSuperposeWithSIFTSMapping(structures: Structure[], options?: { traceOnly?: boolean }): AlignmentResult {
+type IncludeResidueTest = (traceElementOrFirstAtom: StructureElement.Location<Unit.Atomic>, residueIndex: ResidueIndex, startIndex: ElementIndex, endIndex: ElementIndex) => boolean
+
+export function alignAndSuperposeWithSIFTSMapping(
+    structures: Structure[],
+    options?: { traceOnly?: boolean, includeResidueTest?: IncludeResidueTest }
+): AlignmentResult {
     const indexMap = new Map<string, IndexEntry>();
 
     for (let i = 0; i < structures.length; i++) {
-        buildIndex(structures[i], indexMap, i, options?.traceOnly ?? true);
+        buildIndex(structures[i], indexMap, i, options?.traceOnly ?? true, options?.includeResidueTest ?? _includeAllResidues);
     }
 
     const index = Array.from(indexMap.values());
@@ -137,11 +143,16 @@ interface IndexEntry {
     pivots: { [i: number]: [unit: Unit.Atomic, start: ElementIndex, end: ElementIndex] | undefined }
 }
 
-function buildIndex(structure: Structure, index: Map<string, IndexEntry>, sI: number, traceOnly: boolean) {
+function _includeAllResidues() { return true; }
+
+function buildIndex(structure: Structure, index: Map<string, IndexEntry>, sI: number, traceOnly: boolean, includeTest: IncludeResidueTest) {
+    const loc = StructureElement.Location.create<Unit.Atomic>(structure);
+
     for (const unit of structure.units) {
         if (unit.kind !== Unit.Kind.Atomic) continue;
 
         const { elements, model } = unit;
+        loc.unit = unit;
 
         const map = SIFTSMapping.Provider.get(model).value;
         if (!map) return;
@@ -161,9 +172,11 @@ function buildIndex(structure: Structure, index: Map<string, IndexEntry>, sI: nu
 
                 if (!dbName[rI]) continue;
 
+                const traceElement = traceElementIndex[rI];
+
                 let start, end;
                 if (traceOnly) {
-                    start = traceElementIndex[rI];
+                    start = traceElement;
                     if (start === -1) continue;
                     end = start + 1 as ElementIndex;
                 } else {
@@ -171,6 +184,9 @@ function buildIndex(structure: Structure, index: Map<string, IndexEntry>, sI: nu
                     end = elements[residueSegment.end - 1] + 1 as ElementIndex;
                 }
 
+                loc.element = (traceElement >= 0 ? traceElement : start) as ElementIndex;
+                if (!includeTest(loc, rI, start, end)) continue;
+
                 const key = `${dbName[rI]}-${accession[rI]}-${num[rI]}`;
 
                 if (!index.has(key)) {
diff --git a/src/mol-plugin-state/actions/file.ts b/src/mol-plugin-state/actions/file.ts
index 0f92b915b180d8626daf8cab3a1e460bfc19965a..ee81b0793d6aae3808e1a467f75ab1fc00393dfe 100644
--- a/src/mol-plugin-state/actions/file.ts
+++ b/src/mol-plugin-state/actions/file.ts
@@ -83,7 +83,7 @@ export const DownloadFile = StateAction.build({
     display: { name: 'Download File', description: 'Load one or more file from an URL' },
     from: PluginStateObject.Root,
     params: (a, ctx: PluginContext) => {
-        const options = [...ctx.dataFormats.options, ['zip', 'Zip'] as const];
+        const options = [...ctx.dataFormats.options, ['zip', 'Zip'] as const, ['gzip', 'Gzip'] as const];
         return {
             url: PD.Url(''),
             format: PD.Select(options[0][0], options),
@@ -96,17 +96,23 @@ export const DownloadFile = StateAction.build({
 
     await state.transaction(async () => {
         try {
-            if (params.format === 'zip') {
+            if (params.format === 'zip' || params.format === 'gzip') {
                 // TODO: add ReadZipFile transformer so this can be saved as a simple state snaphot,
                 //       would need support for extracting individual files from zip
                 const data = await plugin.builders.data.download({ url: params.url, isBinary: true });
-                const zippedFiles = await unzip(taskCtx, (data.obj?.data as Uint8Array).buffer);
-                for (const [fn, filedata] of Object.entries(zippedFiles)) {
-                    if (!(filedata instanceof Uint8Array) || filedata.length === 0) continue;
+                if (params.format === 'zip') {
+                    const zippedFiles = await unzip(taskCtx, (data.obj?.data as Uint8Array).buffer);
+                    for (const [fn, filedata] of Object.entries(zippedFiles)) {
+                        if (!(filedata instanceof Uint8Array) || filedata.length === 0) continue;
 
-                    const asset = Asset.File(new File([filedata], fn));
+                        const asset = Asset.File(new File([filedata], fn));
 
-                    await processFile(asset, plugin, 'auto', params.visuals);
+                        await processFile(asset, plugin, 'auto', params.visuals);
+                    }
+                } else {
+                    const url = Asset.getUrl(params.url);
+                    const info = getFileInfo(url);
+                    await processFile(Asset.File(new File([data.obj?.data as Uint8Array], info.name)), plugin, 'auto', params.visuals);
                 }
             } else {
                 const provider = plugin.dataFormats.get(params.format);
diff --git a/src/mol-plugin-state/actions/structure.ts b/src/mol-plugin-state/actions/structure.ts
index 630c5d0cdf9f706e3092dd585b217cbb5e29caaa..61765b5a22b2ebc3d3b3592e98c5f0a48357fd20 100644
--- a/src/mol-plugin-state/actions/structure.ts
+++ b/src/mol-plugin-state/actions/structure.ts
@@ -90,6 +90,7 @@ const DownloadStructure = StateAction.build({
                     url: PD.Url(''),
                     format: PD.Select<BuiltInTrajectoryFormat>('mmcif', PD.arrayToOptions(BuiltInTrajectoryFormats.map(f => f[0]), f => f)),
                     isBinary: PD.Boolean(false),
+                    label: PD.Optional(PD.Text('')),
                     options
                 }, { isFlat: true, label: 'URL' })
             })
@@ -104,7 +105,7 @@ const DownloadStructure = StateAction.build({
 
     switch (src.name) {
         case 'url':
-            downloadParams = [{ url: src.params.url, isBinary: src.params.isBinary }];
+            downloadParams = [{ url: src.params.url, isBinary: src.params.isBinary, label: src.params.label || undefined }];
             format = src.params.format;
             break;
         case 'pdb':
diff --git a/src/mol-plugin-state/builder/structure/representation-preset.ts b/src/mol-plugin-state/builder/structure/representation-preset.ts
index b581666d5548291d6082590eca1fadae460ccd94..25f2d1f907f1772b52a9da01ff27d0d91df9bfad 100644
--- a/src/mol-plugin-state/builder/structure/representation-preset.ts
+++ b/src/mol-plugin-state/builder/structure/representation-preset.ts
@@ -41,8 +41,10 @@ export namespace StructureRepresentationPresetProvider {
         quality: PD.Optional(PD.Select<VisualQuality>('auto', VisualQualityOptions)),
         theme: PD.Optional(PD.Group({
             globalName: PD.Optional(PD.Text<ColorTheme.BuiltIn>('')),
+            globalColorParams: PD.Optional(PD.Value<any>({}, { isHidden: true })),
             carbonColor: PD.Optional(PD.Select('chain-id', PD.arrayToOptions(['chain-id', 'operator-name', 'element-symbol'] as const))),
             symmetryColor: PD.Optional(PD.Text<ColorTheme.BuiltIn>('')),
+            symmetryColorParams: PD.Optional(PD.Value<any>({}, { isHidden: true })),
             focus: PD.Optional(PD.Group({
                 name: PD.Optional(PD.Text<ColorTheme.BuiltIn>('')),
                 params: PD.Optional(PD.Value<ColorTheme.BuiltInParams<ColorTheme.BuiltIn>>({} as any))
@@ -76,13 +78,15 @@ export namespace StructureRepresentationPresetProvider {
         if (params.ignoreLight !== void 0) typeParams.ignoreLight = !!params.ignoreLight;
         const color: ColorTheme.BuiltIn | undefined = params.theme?.globalName ? params.theme?.globalName : void 0;
         const ballAndStickColor: ColorTheme.BuiltInParams<'element-symbol'> = params.theme?.carbonColor !== undefined
-            ? { carbonColor: getCarbonColorParams(params.theme?.carbonColor) }
-            : { };
+            ? { carbonColor: getCarbonColorParams(params.theme?.carbonColor), ...params.theme?.globalColorParams }
+            : { ...params.theme?.globalColorParams };
         const symmetryColor: ColorTheme.BuiltIn | undefined = structure && params.theme?.symmetryColor
             ? isSymmetry(structure) ? params.theme?.symmetryColor : color
             : color;
+        const symmetryColorParams = params.theme?.symmetryColorParams ? { ...params.theme?.globalColorParams, ...params.theme?.symmetryColorParams } : { ...params.theme?.globalColorParams };
+        const globalColorParams = params.theme?.globalColorParams ? { ...params.theme?.globalColorParams } : undefined;
 
-        return { update, builder, color, symmetryColor, typeParams, ballAndStickColor };
+        return { update, builder, color, symmetryColor, symmetryColorParams, globalColorParams, typeParams, ballAndStickColor };
     }
 
     export function updateFocusRepr<T extends ColorTheme.BuiltIn>(plugin: PluginContext, structure: Structure, themeName: T | undefined, themeParams: ColorTheme.BuiltInParams<T> | undefined) {
@@ -177,18 +181,18 @@ const polymerAndLigand = StructureRepresentationPresetProvider({
         const waterType = (components.water?.obj?.data?.elementCount || 0) > 50_000 ? 'line' : 'ball-and-stick';
         const lipidType = (components.lipid?.obj?.data?.elementCount || 0) > 20_000 ? 'line' : 'ball-and-stick';
 
-        const { update, builder, typeParams, color, symmetryColor, ballAndStickColor } = reprBuilder(plugin, params, structure);
+        const { update, builder, typeParams, color, symmetryColor, symmetryColorParams, globalColorParams, ballAndStickColor } = reprBuilder(plugin, params, structure);
 
         const representations = {
-            polymer: builder.buildRepresentation(update, components.polymer, { type: 'cartoon', typeParams: { ...typeParams, ...cartoonProps }, color: symmetryColor }, { tag: 'polymer' }),
+            polymer: builder.buildRepresentation(update, components.polymer, { type: 'cartoon', typeParams: { ...typeParams, ...cartoonProps }, color: symmetryColor, colorParams: symmetryColorParams }, { tag: 'polymer' }),
             ligand: builder.buildRepresentation(update, components.ligand, { type: 'ball-and-stick', typeParams, color, colorParams: ballAndStickColor }, { tag: 'ligand' }),
             nonStandard: builder.buildRepresentation(update, components.nonStandard, { type: 'ball-and-stick', typeParams, color, colorParams: ballAndStickColor }, { tag: 'non-standard' }),
             branchedBallAndStick: builder.buildRepresentation(update, components.branched, { type: 'ball-and-stick', typeParams: { ...typeParams, alpha: 0.3 }, color, colorParams: ballAndStickColor }, { tag: 'branched-ball-and-stick' }),
-            branchedSnfg3d: builder.buildRepresentation(update, components.branched, { type: 'carbohydrate', typeParams, color }, { tag: 'branched-snfg-3d' }),
-            water: builder.buildRepresentation(update, components.water, { type: waterType, typeParams: { ...typeParams, alpha: 0.6, visuals: waterType === 'line' ? ['intra-bond', 'element-point'] : undefined }, color, colorParams: { carbonColor: { name: 'element-symbol', params: {} } } }, { tag: 'water' }),
-            ion: builder.buildRepresentation(update, components.ion, { type: 'ball-and-stick', typeParams, color, colorParams: { carbonColor: { name: 'element-symbol', params: {} } } }, { tag: 'ion' }),
-            lipid: builder.buildRepresentation(update, components.lipid, { type: lipidType, typeParams: { ...typeParams, alpha: 0.6, visuals: lipidType === 'line' ? ['intra-bond'] : undefined }, color, colorParams: { carbonColor: { name: 'element-symbol', params: {} } } }, { tag: 'lipid' }),
-            coarse: builder.buildRepresentation(update, components.coarse, { type: 'spacefill', typeParams, color: color || 'chain-id' }, { tag: 'coarse' })
+            branchedSnfg3d: builder.buildRepresentation(update, components.branched, { type: 'carbohydrate', typeParams, color, colorParams: globalColorParams }, { tag: 'branched-snfg-3d' }),
+            water: builder.buildRepresentation(update, components.water, { type: waterType, typeParams: { ...typeParams, alpha: 0.6, visuals: waterType === 'line' ? ['intra-bond', 'element-point'] : undefined }, color, colorParams: { carbonColor: { name: 'element-symbol', params: {} }, ...globalColorParams } }, { tag: 'water' }),
+            ion: builder.buildRepresentation(update, components.ion, { type: 'ball-and-stick', typeParams, color, colorParams: { carbonColor: { name: 'element-symbol', params: {} }, ...globalColorParams } }, { tag: 'ion' }),
+            lipid: builder.buildRepresentation(update, components.lipid, { type: lipidType, typeParams: { ...typeParams, alpha: 0.6, visuals: lipidType === 'line' ? ['intra-bond'] : undefined }, color, colorParams: { carbonColor: { name: 'element-symbol', params: {} }, ...globalColorParams } }, { tag: 'lipid' }),
+            coarse: builder.buildRepresentation(update, components.coarse, { type: 'spacefill', typeParams, color: color || 'chain-id', colorParams: globalColorParams }, { tag: 'coarse' })
         };
 
         await update.commit({ revertOnError: false });
@@ -223,11 +227,11 @@ const proteinAndNucleic = StructureRepresentationPresetProvider({
             smoothness: structure.isCoarseGrained ? 1.0 : 1.5,
         };
 
-        const { update, builder, typeParams, symmetryColor } = reprBuilder(plugin, params, structure);
+        const { update, builder, typeParams, symmetryColor, symmetryColorParams } = reprBuilder(plugin, params, structure);
 
         const representations = {
-            protein: builder.buildRepresentation(update, components.protein, { type: 'cartoon', typeParams: { ...typeParams, ...cartoonProps }, color: symmetryColor }, { tag: 'protein' }),
-            nucleic: builder.buildRepresentation(update, components.nucleic, { type: 'gaussian-surface', typeParams: { ...typeParams, ...gaussianProps }, color: symmetryColor }, { tag: 'nucleic' })
+            protein: builder.buildRepresentation(update, components.protein, { type: 'cartoon', typeParams: { ...typeParams, ...cartoonProps }, color: symmetryColor, colorParams: symmetryColorParams }, { tag: 'protein' }),
+            nucleic: builder.buildRepresentation(update, components.nucleic, { type: 'gaussian-surface', typeParams: { ...typeParams, ...gaussianProps }, color: symmetryColor, colorParams: symmetryColorParams }, { tag: 'nucleic' })
         };
 
         await update.commit({ revertOnError: true });
@@ -275,11 +279,11 @@ const coarseSurface = StructureRepresentationPresetProvider({
             });
         }
 
-        const { update, builder, typeParams, symmetryColor } = reprBuilder(plugin, params, structure);
+        const { update, builder, typeParams, symmetryColor, symmetryColorParams } = reprBuilder(plugin, params, structure);
 
         const representations = {
-            polymer: builder.buildRepresentation(update, components.polymer, { type: 'gaussian-surface', typeParams: { ...typeParams, ...gaussianProps }, color: symmetryColor }, { tag: 'polymer' }),
-            lipid: builder.buildRepresentation(update, components.lipid, { type: 'gaussian-surface', typeParams: { ...typeParams, ...gaussianProps }, color: symmetryColor }, { tag: 'lipid' })
+            polymer: builder.buildRepresentation(update, components.polymer, { type: 'gaussian-surface', typeParams: { ...typeParams, ...gaussianProps }, color: symmetryColor, colorParams: symmetryColorParams }, { tag: 'polymer' }),
+            lipid: builder.buildRepresentation(update, components.lipid, { type: 'gaussian-surface', typeParams: { ...typeParams, ...gaussianProps }, color: symmetryColor, colorParams: symmetryColorParams }, { tag: 'lipid' })
         };
 
         await update.commit({ revertOnError: true });
@@ -309,10 +313,10 @@ const polymerCartoon = StructureRepresentationPresetProvider({
             sizeFactor: structure.isCoarseGrained ? 0.8 : 0.2
         };
 
-        const { update, builder, typeParams, symmetryColor } = reprBuilder(plugin, params, structure);
+        const { update, builder, typeParams, symmetryColor, symmetryColorParams } = reprBuilder(plugin, params, structure);
 
         const representations = {
-            polymer: builder.buildRepresentation(update, components.polymer, { type: 'cartoon', typeParams: { ...typeParams, ...cartoonProps }, color: symmetryColor }, { tag: 'polymer' })
+            polymer: builder.buildRepresentation(update, components.polymer, { type: 'cartoon', typeParams: { ...typeParams, ...cartoonProps }, color: symmetryColor, colorParams: symmetryColorParams }, { tag: 'polymer' })
         };
 
         await update.commit({ revertOnError: true });
@@ -367,9 +371,9 @@ const atomicDetail = StructureRepresentationPresetProvider({
             });
         }
 
-        const { update, builder, typeParams, color, ballAndStickColor } = reprBuilder(plugin, params, structure);
+        const { update, builder, typeParams, color, ballAndStickColor, globalColorParams } = reprBuilder(plugin, params, structure);
         const colorParams = lowResidueElementRatio && !bondsGiven
-            ? { carbonColor: { name: 'element-symbol', params: {} } }
+            ? { carbonColor: { name: 'element-symbol', params: {} }, ...globalColorParams }
             : ballAndStickColor;
 
         const representations = {
@@ -377,7 +381,7 @@ const atomicDetail = StructureRepresentationPresetProvider({
         };
         if (showCarbohydrateSymbol) {
             Object.assign(representations, {
-                snfg3d: builder.buildRepresentation(update, components.branched, { type: 'carbohydrate', typeParams: { ...typeParams, alpha: 0.4, visuals: ['carbohydrate-symbol'] }, color }, { tag: 'snfg-3d' }),
+                snfg3d: builder.buildRepresentation(update, components.branched, { type: 'carbohydrate', typeParams: { ...typeParams, alpha: 0.4, visuals: ['carbohydrate-symbol'] }, color, colorParams: globalColorParams }, { tag: 'snfg-3d' }),
             });
         }
 
diff --git a/src/mol-plugin-ui/custom/volume.tsx b/src/mol-plugin-ui/custom/volume.tsx
index 8a6d9b1b167e3146f45ca7fcdc442a7840f412a1..0a13a436e087795313b51dd9ac5999822b4df229 100644
--- a/src/mol-plugin-ui/custom/volume.tsx
+++ b/src/mol-plugin-ui/custom/volume.tsx
@@ -1,7 +1,8 @@
 /**
- * Copyright (c) 2019 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2019-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author David Sehnal <david.sehnal@gmail.com>
+ * @author Adam Midlik <midlik@gmail.com>
  */
 
 import { PluginUIComponent } from '../base';
@@ -199,6 +200,9 @@ export class VolumeStreamingCustomControls extends PluginUIComponent<StateTransf
             const viewParams = { ...oldView };
             if (value.name === 'selection-box') {
                 viewParams.radius = value.params.radius;
+            } else if (value.name === 'camera-target') {
+                viewParams.radius = value.params.radius;
+                viewParams.dynamicDetailLevel = value.params.dynamicDetailLevel;
             } else if (value.name === 'box') {
                 viewParams.bottomLeft = value.params.bottomLeft;
                 viewParams.topRight = value.params.topRight;
@@ -240,13 +244,23 @@ export class VolumeStreamingCustomControls extends PluginUIComponent<StateTransf
         const pivot = isEM ? 'em' : '2fo-fc';
 
         const params = this.props.params as VolumeStreaming.Params;
-        const entry = ((this.props.info.params as VolumeStreaming.ParamDefinition)
-            .entry.map(params.entry.name) as PD.Group<VolumeStreaming.EntryParamDefinition>);
+        const entry = (this.props.info.params as VolumeStreaming.ParamDefinition)
+            .entry.map(params.entry.name) as PD.Group<VolumeStreaming.EntryParamDefinition>;
         const detailLevel = entry.params.detailLevel;
-        const isRelative = ((params.entry.params.channels as any)[pivot].isoValue as Volume.IsoValue).kind === 'relative';
+        const dynamicDetailLevel = {
+            ...detailLevel,
+            label: 'Dynamic Detail',
+            defaultValue: (entry.params.view as any).map('camera-target').params.dynamicDetailLevel.defaultValue,
+        };
+        const selectionDetailLevel = {
+            ...detailLevel,
+            label: 'Selection Detail',
+            defaultValue: (entry.params.view as any).map('auto').params.selectionDetailLevel.defaultValue,
+        };
 
         const sampling = b.info.header.sampling[0];
 
+        const isRelative = ((params.entry.params.channels as any)[pivot].isoValue as Volume.IsoValue).kind === 'relative';
         const isRelativeParam = PD.Boolean(isRelative, { description: 'Use normalized or absolute isocontour scale.', label: 'Normalized' });
 
         const isUnbounded = !!(params.entry.params.view.params as any).isUnbounded;
@@ -274,6 +288,13 @@ export class VolumeStreamingCustomControls extends PluginUIComponent<StateTransf
                     isRelative: isRelativeParam,
                     isUnbounded: isUnboundedParam,
                 }, { description: 'Box around focused element.' }),
+                'camera-target': PD.Group({
+                    radius: PD.Numeric(0.5, { min: 0, max: 1, step: 0.05 }, { description: 'Radius within which the volume is shown (relative to the field of view).' }),
+                    detailLevel: { ...detailLevel, isHidden: true },
+                    dynamicDetailLevel: dynamicDetailLevel,
+                    isRelative: isRelativeParam,
+                    isUnbounded: isUnboundedParam,
+                }, { description: 'Box around camera target.' }),
                 'cell': PD.Group({
                     detailLevel,
                     isRelative: isRelativeParam,
@@ -282,12 +303,11 @@ export class VolumeStreamingCustomControls extends PluginUIComponent<StateTransf
                 'auto': PD.Group({
                     radius: PD.Numeric(5, { min: 0, max: 50, step: 0.5 }, { description: 'Radius in \u212B within which the volume is shown.' }),
                     detailLevel,
-                    selectionDetailLevel: { ...detailLevel, label: 'Selection Detail' },
+                    selectionDetailLevel: selectionDetailLevel,
                     isRelative: isRelativeParam,
                     isUnbounded: isUnboundedParam,
                 }, { description: 'Box around focused element.' }),
-                // 'auto': PD.Group({  }), // TODO based on camera distance/active selection/whatever, show whole structure or slice.
-            }, { options: VolumeStreaming.ViewTypeOptions, description: 'Controls what of the volume is displayed. "Off" hides the volume alltogether. "Bounded box" shows the volume inside the given box. "Around Focus" shows the volume around the element/atom last interacted with. "Whole Structure" shows the volume for the whole structure.' })
+            }, { options: VolumeStreaming.ViewTypeOptions, description: 'Controls what of the volume is displayed. "Off" hides the volume alltogether. "Bounded box" shows the volume inside the given box. "Around Focus" shows the volume around the element/atom last interacted with. "Around Camera" shows the volume around the point the camera is targeting. "Whole Structure" shows the volume for the whole structure.' })
         };
         const options = {
             entry: params.entry.name,
@@ -299,6 +319,7 @@ export class VolumeStreamingCustomControls extends PluginUIComponent<StateTransf
                     bottomLeft: (params.entry.params.view.params as any).bottomLeft,
                     topRight: (params.entry.params.view.params as any).topRight,
                     selectionDetailLevel: (params.entry.params.view.params as any).selectionDetailLevel,
+                    dynamicDetailLevel: (params.entry.params.view.params as any).dynamicDetailLevel,
                     isRelative,
                     isUnbounded
                 }
diff --git a/src/mol-plugin-ui/viewport/help.tsx b/src/mol-plugin-ui/viewport/help.tsx
index 468e63959c0d35cb68ddc733079f6b1306f451b2..9146fb6a7e461ccfc2d9be0f3b1ee310723b3c53 100644
--- a/src/mol-plugin-ui/viewport/help.tsx
+++ b/src/mol-plugin-ui/viewport/help.tsx
@@ -7,14 +7,15 @@
 import * as React from 'react';
 import { Binding } from '../../mol-util/binding';
 import { PluginUIComponent } from '../base';
-import { StateTransformer, StateSelection } from '../../mol-state';
+import { StateTransformer, StateSelection, State } from '../../mol-state';
 import { SelectLoci } from '../../mol-plugin/behavior/dynamic/representation';
 import { FocusLoci } from '../../mol-plugin/behavior/dynamic/representation';
 import { Icon, ArrowDropDownSvg, ArrowRightSvg, CameraSvg } from '../controls/icons';
 import { Button } from '../controls/common';
+import { memoizeLatest } from '../../mol-util/memoize';
 
 function getBindingsList(bindings: { [k: string]: Binding }) {
-    return Object.keys(bindings).map(k => [k, bindings[k]] as [string, Binding]);
+    return Object.keys(bindings).map(k => [k, bindings[k]] as [string, Binding]).filter(b => Binding.isBinding(b[1]));
 }
 
 export class BindingsHelp extends React.PureComponent<{ bindings: { [k: string]: Binding } }> {
@@ -77,19 +78,30 @@ export class ViewportHelpContent extends PluginUIComponent<{ selectOnly?: boolea
         this.subscribe(this.plugin.events.canvas3d.settingsUpdated, () => this.forceUpdate());
     }
 
-    render() {
-        const interactionBindings: { [k: string]: Binding } = {};
-        this.plugin.spec.behaviors.forEach(b => {
-            const { bindings } = b.defaultParams;
-            if (bindings) Object.assign(interactionBindings, bindings);
+    getInteractionBindings = memoizeLatest((cells: State.Cells) => {
+        let interactionBindings: { [k: string]: Binding } | undefined = void 0;
+
+        cells.forEach(c => {
+            const params = c.params?.values;
+            if (params?.bindings && Object.keys(params.bindings).length > 0) {
+                if (!interactionBindings) interactionBindings = { };
+                Object.assign(interactionBindings, params.bindings);
+            }
         });
+
+        return interactionBindings;
+    });
+
+    render() {
+        const interactionBindings = this.getInteractionBindings(this.plugin.state.behaviors.cells);
+
         return <>
             {(!this.props.selectOnly && this.plugin.canvas3d) && <HelpGroup key='trackball' header='Moving in 3D'>
                 <BindingsHelp bindings={this.plugin.canvas3d.props.trackball.bindings} />
             </HelpGroup>}
-            <HelpGroup key='interactions' header='Mouse Controls'>
+            {!!interactionBindings && <HelpGroup key='interactions' header='Mouse Controls'>
                 <BindingsHelp bindings={interactionBindings} />
-            </HelpGroup>
+            </HelpGroup>}
         </>;
     }
 }
diff --git a/src/mol-plugin-ui/viewport/simple-settings.tsx b/src/mol-plugin-ui/viewport/simple-settings.tsx
index 73f819331fbc1188e0d594da430e1fb3ec6af0be..122e11f8a64fa9d987db949223698ec55fc66771 100644
--- a/src/mol-plugin-ui/viewport/simple-settings.tsx
+++ b/src/mol-plugin-ui/viewport/simple-settings.tsx
@@ -8,8 +8,10 @@
 import { produce } from 'immer';
 import { Canvas3DParams, Canvas3DProps } from '../../mol-canvas3d/canvas3d';
 import { PluginCommands } from '../../mol-plugin/commands';
+import { PluginConfig } from '../../mol-plugin/config';
 import { StateTransform } from '../../mol-state';
 import { Color } from '../../mol-util/color';
+import { deepClone } from '../../mol-util/object';
 import { ParamDefinition as PD } from '../../mol-util/param-definition';
 import { ParamMapping } from '../../mol-util/param-mapping';
 import { Mutable } from '../../mol-util/type-helpers';
@@ -50,7 +52,8 @@ const SimpleSettingsParams = {
     camera: Canvas3DParams.camera,
     background: PD.Group({
         color: PD.Color(Color(0xFCFBF9), { label: 'Background', description: 'Custom background color' }),
-        transparent: PD.Boolean(false)
+        transparent: PD.Boolean(false),
+        style: Canvas3DParams.postprocessing.params.background,
     }, { pivot: 'color' }),
     lighting: PD.Group({
         occlusion: Canvas3DParams.postprocessing.params.occlusion,
@@ -75,6 +78,13 @@ const SimpleSettingsMapping = ParamMapping({
             if (controls.left !== 'none') options.push(['left', LayoutOptions.left]);
             params.layout.options = options;
         }
+        const bgStyles = ctx.config.get(PluginConfig.Background.Styles) || [];
+        if (bgStyles.length > 0) {
+            Object.assign(params.background.params.style, {
+                presets: deepClone(bgStyles),
+                isFlat: false, // so the presets menu is shown
+            });
+        }
         return params;
     },
     target(ctx: PluginUIContext) {
@@ -97,7 +107,8 @@ const SimpleSettingsMapping = ParamMapping({
             camera: canvas.camera,
             background: {
                 color: renderer.backgroundColor,
-                transparent: canvas.transparentBackground
+                transparent: canvas.transparentBackground,
+                style: canvas.postprocessing.background,
             },
             lighting: {
                 occlusion: canvas.postprocessing.occlusion,
@@ -117,6 +128,7 @@ const SimpleSettingsMapping = ParamMapping({
         canvas.renderer.backgroundColor = s.background.color;
         canvas.postprocessing.occlusion = s.lighting.occlusion;
         canvas.postprocessing.outline = s.lighting.outline;
+        canvas.postprocessing.background = s.background.style;
         canvas.cameraFog = s.lighting.fog;
         canvas.cameraClipping = {
             radius: s.clipping.radius,
diff --git a/src/mol-plugin/behavior/behavior.ts b/src/mol-plugin/behavior/behavior.ts
index b65203e9c88a52d7399c6c10a6f473153fad7b62..ddbb603c351f26f63978ca998ea3d5a5bab7475b 100644
--- a/src/mol-plugin/behavior/behavior.ts
+++ b/src/mol-plugin/behavior/behavior.ts
@@ -1,7 +1,8 @@
 /**
- * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2018-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author David Sehnal <david.sehnal@gmail.com>
+ * @author Adam Midlik <midlik@gmail.com>
  */
 
 import { PluginStateTransform, PluginStateObject } from '../../mol-plugin-state/objects';
@@ -144,8 +145,18 @@ namespace PluginBehavior {
         protected subscribeCommand<T>(cmd: PluginCommand<T>, action: PluginCommand.Action<T>) {
             this.subs.push(cmd.subscribe(this.plugin, action));
         }
-        protected subscribeObservable<T>(o: Observable<T>, action: (v: T) => void) {
-            this.subs.push(o.subscribe(action));
+        protected subscribeObservable<T>(o: Observable<T>, action: (v: T) => void): PluginCommand.Subscription {
+            const sub = o.subscribe(action);
+            this.subs.push(sub);
+            return {
+                unsubscribe: () => {
+                    const idx = this.subs.indexOf(sub);
+                    if (idx >= 0) {
+                        this.subs.splice(idx, 1);
+                        sub.unsubscribe();
+                    }
+                }
+            };
         }
         dispose(): void {
             for (const s of this.subs) s.unsubscribe();
diff --git a/src/mol-plugin/behavior/dynamic/volume-streaming/behavior.ts b/src/mol-plugin/behavior/dynamic/volume-streaming/behavior.ts
index 071e4544ac1f234044caa0d222ad3953025c44bf..4cec92de8bb504f853a67911c156743f13c22453 100644
--- a/src/mol-plugin/behavior/dynamic/volume-streaming/behavior.ts
+++ b/src/mol-plugin/behavior/dynamic/volume-streaming/behavior.ts
@@ -1,8 +1,9 @@
 /**
- * Copyright (c) 2019-2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2019-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author David Sehnal <david.sehnal@gmail.com>
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
+ * @author Adam Midlik <midlik@gmail.com>
  */
 
 import { ParamDefinition as PD } from '../../../../mol-util/param-definition';
@@ -24,6 +25,10 @@ import { PluginContext } from '../../../context';
 import { EmptyLoci, Loci, isEmptyLoci } from '../../../../mol-model/loci';
 import { Asset } from '../../../../mol-util/assets';
 import { GlobalModelTransformInfo } from '../../../../mol-model/structure/model/properties/global-transform';
+import { distinctUntilChanged, filter, map, Observable, throttleTime } from 'rxjs';
+import { Camera } from '../../../../mol-canvas3d/camera';
+import { PluginCommand } from '../../../command';
+import { SingleAsyncQueue } from '../../../../mol-util/single-async-queue';
 
 export class VolumeStreaming extends PluginStateObject.CreateBehavior<VolumeStreaming.Behavior>({ name: 'Volume Streaming' }) { }
 
@@ -53,7 +58,7 @@ export namespace VolumeStreaming {
         valuesInfo: [{ mean: 0, min: -1, max: 1, sigma: 0.1 }, { mean: 0, min: -1, max: 1, sigma: 0.1 }]
     };
 
-    export function createParams(options: { data?: VolumeServerInfo.Data, defaultView?: ViewTypes, channelParams?: DefaultChannelParams } = { }) {
+    export function createParams(options: { data?: VolumeServerInfo.Data, defaultView?: ViewTypes, channelParams?: DefaultChannelParams } = {}) {
         const { data, defaultView, channelParams } = options;
         const map = new Map<string, VolumeServerInfo.EntryData>();
         if (data) data.entries.forEach(d => map.set(d.dataId, d));
@@ -68,7 +73,7 @@ export namespace VolumeStreaming {
     export type EntryParams = PD.Values<EntryParamDefinition>
 
     export function createEntryParams(options: { entryData?: VolumeServerInfo.EntryData, defaultView?: ViewTypes, structure?: Structure, channelParams?: DefaultChannelParams }) {
-        const { entryData, defaultView, structure, channelParams = { } } = options;
+        const { entryData, defaultView, structure, channelParams = {} } = options;
 
         // fake the info
         const info = entryData || { kind: 'em', header: { sampling: [fakeSampling], availablePrecisions: [{ precision: 0, maxVoxels: 0 }] }, emDefaultContourLevel: Volume.IsoValue.relative(0) };
@@ -86,19 +91,24 @@ export namespace VolumeStreaming {
                     bottomLeft: PD.Vec3(Vec3.create(0, 0, 0), {}, { isHidden: true }),
                     topRight: PD.Vec3(Vec3.create(0, 0, 0), {}, { isHidden: true }),
                 }, { description: 'Box around focused element.', isFlat: true }),
+                'camera-target': PD.Group({
+                    radius: PD.Numeric(0.5, { min: 0, max: 1, step: 0.05 }, { description: 'Radius within which the volume is shown (relative to the field of view).' }),
+                    // Minimal detail level for the inside of the zoomed region (real detail can be higher, depending on the region size)
+                    dynamicDetailLevel: createDetailParams(info.header.availablePrecisions, 0, { label: 'Dynamic Detail' }),
+                    bottomLeft: PD.Vec3(Vec3.create(0, 0, 0), {}, { isHidden: true }),
+                    topRight: PD.Vec3(Vec3.create(0, 0, 0), {}, { isHidden: true }),
+                }, { description: 'Box around camera target.', isFlat: true }),
                 'cell': PD.Group<{}>({}),
                 // Show selection-box if available and cell otherwise.
                 'auto': PD.Group({
                     radius: PD.Numeric(5, { min: 0, max: 50, step: 0.5 }, { description: 'Radius in \u212B within which the volume is shown.' }),
-                    selectionDetailLevel: PD.Select<number>(Math.min(6, info.header.availablePrecisions.length - 1),
-                        info.header.availablePrecisions.map((p, i) => [i, `${i + 1} [ ${Math.pow(p.maxVoxels, 1 / 3) | 0}^3 cells ]`] as [number, string]), { label: 'Selection Detail', description: 'Determines the maximum number of voxels. Depending on the size of the volume options are in the range from 0 (0.52M voxels) to 6 (25.17M voxels).' }),
+                    selectionDetailLevel: createDetailParams(info.header.availablePrecisions, 6, { label: 'Selection Detail' }),
                     isSelection: PD.Boolean(false, { isHidden: true }),
                     bottomLeft: PD.Vec3(box.min, {}, { isHidden: true }),
                     topRight: PD.Vec3(box.max, {}, { isHidden: true }),
                 }, { description: 'Box around focused element.', isFlat: true })
             }, { options: ViewTypeOptions, description: 'Controls what of the volume is displayed. "Off" hides the volume alltogether. "Bounded box" shows the volume inside the given box. "Around Interaction" shows the volume around the focused element/atom. "Whole Structure" shows the volume for the whole structure.' }),
-            detailLevel: PD.Select<number>(Math.min(3, info.header.availablePrecisions.length - 1),
-                info.header.availablePrecisions.map((p, i) => [i, `${i + 1} [ ${Math.pow(p.maxVoxels, 1 / 3) | 0}^3 cells ]`] as [number, string]), { description: 'Determines the maximum number of voxels. Depending on the size of the volume options are in the range from 0 (0.52M voxels) to 6 (25.17M voxels).' }),
+            detailLevel: createDetailParams(info.header.availablePrecisions, 3),
             channels: info.kind === 'em'
                 ? PD.Group({
                     'em': channelParam('EM', Color(0x638F8F), info.emDefaultContourLevel || Volume.IsoValue.relative(1), info.header.sampling[0].valuesInfo[0], channelParams['em'])
@@ -111,13 +121,40 @@ export namespace VolumeStreaming {
         };
     }
 
-    export const ViewTypeOptions = [['off', 'Off'], ['box', 'Bounded Box'], ['selection-box', 'Around Focus'], ['cell', 'Whole Structure'], ['auto', 'Auto']] as [ViewTypes, string][];
+    function createDetailParams(availablePrecisions: VolumeServerHeader.DetailLevel[], preferredPrecision: number, info?: PD.Info) {
+        return PD.Select<number>(Math.min(preferredPrecision, availablePrecisions.length - 1),
+            availablePrecisions.map((p, i) => [i, `${i + 1} [ ${Math.pow(p.maxVoxels, 1 / 3) | 0}^3 cells ]`] as [number, string]),
+            {
+                description: 'Determines the maximum number of voxels. Depending on the size of the volume options are in the range from 1 (0.52M voxels) to 7 (25.17M voxels).',
+                ...info
+            }
+        );
+    }
 
-    export type ViewTypes = 'off' | 'box' | 'selection-box' | 'cell' | 'auto'
+    export function copyParams(origParams: Params): Params {
+        return {
+            entry: {
+                name: origParams.entry.name,
+                params: {
+                    detailLevel: origParams.entry.params.detailLevel,
+                    channels: origParams.entry.params.channels,
+                    view: {
+                        name: origParams.entry.params.view.name,
+                        params: { ...origParams.entry.params.view.params } as any,
+                    }
+                }
+            }
+        };
+    }
+
+    export const ViewTypeOptions = [['off', 'Off'], ['box', 'Bounded Box'], ['selection-box', 'Around Focus'], ['camera-target', 'Around Camera'], ['cell', 'Whole Structure'], ['auto', 'Auto']] as [ViewTypes, string][];
+
+    export type ViewTypes = 'off' | 'box' | 'selection-box' | 'camera-target' | 'cell' | 'auto'
 
     export type ParamDefinition = ReturnType<typeof createParams>
     export type Params = PD.Values<ParamDefinition>
 
+
     type ChannelsInfo = { [name in ChannelType]?: { isoValue: Volume.IsoValue, color: Color, wireframe: boolean, opacity: number } }
     type ChannelsData = { [name in 'EM' | '2FO-FC' | 'FO-FC']?: Volume }
 
@@ -140,6 +177,14 @@ export namespace VolumeStreaming {
         private lastLoci: StructureElement.Loci | EmptyLoci = EmptyLoci;
         private ref: string = '';
         public infoMap: Map<string, VolumeServerInfo.EntryData>;
+        private updateQueue: SingleAsyncQueue;
+        private cameraTargetObservable = this.plugin.canvas3d!.didDraw!.pipe(
+            throttleTime(500, undefined, { 'leading': true, 'trailing': true }),
+            map(() => this.plugin.canvas3d?.camera.getSnapshot()),
+            distinctUntilChanged((a, b) => this.isCameraTargetSame(a, b)),
+            filter(a => a !== undefined),
+        ) as Observable<Camera.Snapshot>;
+        private cameraTargetSubscription?: PluginCommand.Subscription = undefined;
 
         channels: Channels = {};
 
@@ -163,6 +208,9 @@ export namespace VolumeStreaming {
             if (this.params.entry.params.view.name === 'auto' && this.params.entry.params.view.params.isSelection) {
                 detail = this.params.entry.params.view.params.selectionDetailLevel;
             }
+            if (this.params.entry.params.view.name === 'camera-target' && box) {
+                detail = this.decideDetail(box, this.params.entry.params.view.params.dynamicDetailLevel);
+            }
 
             url += `?detail=${detail}`;
 
@@ -201,58 +249,21 @@ export namespace VolumeStreaming {
             return ret;
         }
 
-        private updateSelectionBoxParams(box: Box3D) {
-            if (this.params.entry.params.view.name !== 'selection-box') return;
-
-            const state = this.plugin.state.data;
-            const newParams: Params = {
-                ...this.params,
-                entry: {
-                    name: this.params.entry.name,
-                    params: {
-                        ...this.params.entry.params,
-                        view: {
-                            name: 'selection-box' as const,
-                            params: {
-                                radius: this.params.entry.params.view.params.radius,
-                                bottomLeft: box.min,
-                                topRight: box.max
-                            }
-                        }
-                    }
-                }
-            };
-            const update = state.build().to(this.ref).update(newParams);
-
-            PluginCommands.State.Update(this.plugin, { state, tree: update, options: { doNotUpdateCurrent: true } });
-        }
-
-        private updateAutoParams(box: Box3D | undefined, isSelection: boolean) {
-            if (this.params.entry.params.view.name !== 'auto') return;
+        private async updateParams(box: Box3D | undefined, autoIsSelection: boolean = false) {
+            const newParams = copyParams(this.params);
+            const viewType = newParams.entry.params.view.name;
+            if (viewType !== 'off' && viewType !== 'cell') {
+                newParams.entry.params.view.params.bottomLeft = box?.min || Vec3.zero();
+                newParams.entry.params.view.params.topRight = box?.max || Vec3.zero();
+            }
+            if (viewType === 'auto') {
+                newParams.entry.params.view.params.isSelection = autoIsSelection;
+            }
 
             const state = this.plugin.state.data;
-            const newParams: Params = {
-                ...this.params,
-                entry: {
-                    name: this.params.entry.name,
-                    params: {
-                        ...this.params.entry.params,
-                        view: {
-                            name: 'auto' as const,
-                            params: {
-                                radius: this.params.entry.params.view.params.radius,
-                                selectionDetailLevel: this.params.entry.params.view.params.selectionDetailLevel,
-                                isSelection,
-                                bottomLeft: box?.min || Vec3.zero(),
-                                topRight: box?.max || Vec3.zero()
-                            }
-                        }
-                    }
-                }
-            };
             const update = state.build().to(this.ref).update(newParams);
 
-            PluginCommands.State.Update(this.plugin, { state, tree: update, options: { doNotUpdateCurrent: true } });
+            await PluginCommands.State.Update(this.plugin, { state, tree: update, options: { doNotUpdateCurrent: true } });
         }
 
         private getStructureRoot() {
@@ -303,6 +314,18 @@ export namespace VolumeStreaming {
             }
         }
 
+        private isCameraTargetSame(a?: Camera.Snapshot, b?: Camera.Snapshot): boolean {
+            if (!a || !b) return false;
+            const targetSame = Vec3.equals(a.target, b.target);
+            const sqDistA = Vec3.squaredDistance(a.target, a.position);
+            const sqDistB = Vec3.squaredDistance(b.target, b.position);
+            const distanceSame = Math.abs(sqDistA - sqDistB) / sqDistA < 1e-3;
+            return targetSame && distanceSame;
+        }
+        private cameraTargetDistance(snapshot: Camera.Snapshot): number {
+            return Vec3.distance(snapshot.target, snapshot.position);
+        }
+
         private _invTransform: Mat4 = Mat4();
         private getBoxFromLoci(loci: StructureElement.Loci | EmptyLoci): Box3D {
             if (Loci.isEmpty(loci) || isEmptyLoci(loci)) {
@@ -328,39 +351,82 @@ export namespace VolumeStreaming {
         }
 
         private updateAuto(loci: StructureElement.Loci | EmptyLoci) {
-            // if (Loci.areEqual(this.lastLoci, loci)) {
-            //     this.lastLoci = EmptyLoci;
-            //     this.updateSelectionBoxParams(Box3D.empty());
-            //     return;
-            // }
-
-            this.lastLoci = loci;
-
-            if (isEmptyLoci(loci)) {
-                this.updateAutoParams(this.info.kind === 'x-ray' ? this.data.structure.boundary.box : void 0, false);
-                return;
-            }
-
-            const box = this.getBoxFromLoci(loci);
-            this.updateAutoParams(box, true);
+            this.updateQueue.enqueue(async () => {
+                this.lastLoci = loci;
+                if (isEmptyLoci(loci)) {
+                    await this.updateParams(this.info.kind === 'x-ray' ? this.data.structure.boundary.box : void 0, false);
+                } else {
+                    await this.updateParams(this.getBoxFromLoci(loci), true);
+                }
+            });
         }
 
         private updateSelectionBox(loci: StructureElement.Loci | EmptyLoci) {
-            if (Loci.areEqual(this.lastLoci, loci)) {
-                this.lastLoci = EmptyLoci;
-                this.updateSelectionBoxParams(Box3D());
-                return;
-            }
+            this.updateQueue.enqueue(async () => {
+                if (Loci.areEqual(this.lastLoci, loci)) {
+                    this.lastLoci = EmptyLoci;
+                } else {
+                    this.lastLoci = loci;
+                }
+                const box = this.getBoxFromLoci(this.lastLoci);
+                await this.updateParams(box);
+            });
+        }
 
-            this.lastLoci = loci;
+        private updateCameraTarget(snapshot: Camera.Snapshot) {
+            this.updateQueue.enqueue(async () => {
+                const origManualReset = this.plugin.canvas3d?.props.camera.manualReset;
+                try {
+                    if (!origManualReset) this.plugin.canvas3d?.setProps({ camera: { manualReset: true } });
+                    const box = this.boxFromCameraTarget(snapshot, true);
+                    await this.updateParams(box);
+                } finally {
+                    if (!origManualReset) this.plugin.canvas3d?.setProps({ camera: { manualReset: origManualReset } });
+                }
+            });
+        }
 
-            if (isEmptyLoci(loci)) {
-                this.updateSelectionBoxParams(Box3D());
-                return;
+        private boxFromCameraTarget(snapshot: Camera.Snapshot, boundByBoundarySize: boolean): Box3D {
+            const target = snapshot.target;
+            const distance = this.cameraTargetDistance(snapshot);
+            const top = Math.tan(0.5 * snapshot.fov) * distance;
+            let radius = top;
+            const viewport = this.plugin.canvas3d?.camera.viewport;
+            if (viewport && viewport.width > viewport.height) {
+                radius *= viewport.width / viewport.height;
+            }
+            const relativeRadius = this.params.entry.params.view.name === 'camera-target' ? this.params.entry.params.view.params.radius : 0.5;
+            radius *= relativeRadius;
+            let radiusX, radiusY, radiusZ;
+            if (boundByBoundarySize) {
+                const bBoxSize = Vec3.zero();
+                Box3D.size(bBoxSize, this.data.structure.boundary.box);
+                radiusX = Math.min(radius, 0.5 * bBoxSize[0]);
+                radiusY = Math.min(radius, 0.5 * bBoxSize[1]);
+                radiusZ = Math.min(radius, 0.5 * bBoxSize[2]);
+            } else {
+                radiusX = radiusY = radiusZ = radius;
             }
+            return Box3D.create(
+                Vec3.create(target[0] - radiusX, target[1] - radiusY, target[2] - radiusZ),
+                Vec3.create(target[0] + radiusX, target[1] + radiusY, target[2] + radiusZ)
+            );
+        }
 
-            const box = this.getBoxFromLoci(loci);
-            this.updateSelectionBoxParams(box);
+        private decideDetail(box: Box3D, baseDetail: number): number {
+            const cellVolume = this.info.kind === 'x-ray'
+                ? Box3D.volume(this.data.structure.boundary.box)
+                : this.info.header.spacegroup.size.reduce((a, b) => a * b, 1);
+            const boxVolume = Box3D.volume(box);
+            let ratio = boxVolume / cellVolume;
+            const maxDetail = this.info.header.availablePrecisions.length - 1;
+            let detail = baseDetail;
+            while (ratio <= 0.5 && detail < maxDetail) {
+                ratio *= 2;
+                detail += 1;
+            }
+            // console.log(`Decided dynamic detail: ${detail}, (base detail: ${baseDetail}, box/cell volume ratio: ${boxVolume / cellVolume})`);
+            return detail;
         }
 
         async update(params: Params) {
@@ -369,6 +435,11 @@ export namespace VolumeStreaming {
             this.params = params;
             let box: Box3D | undefined = void 0, emptyData = false;
 
+            if (params.entry.params.view.name !== 'camera-target' && this.cameraTargetSubscription) {
+                this.cameraTargetSubscription.unsubscribe();
+                this.cameraTargetSubscription = undefined;
+            }
+
             switch (params.entry.params.view.name) {
                 case 'off':
                     emptyData = true;
@@ -388,6 +459,12 @@ export namespace VolumeStreaming {
                     Box3D.expand(box, box, Vec3.create(r, r, r));
                     break;
                 }
+                case 'camera-target':
+                    if (!this.cameraTargetSubscription) {
+                        this.cameraTargetSubscription = this.subscribeObservable(this.cameraTargetObservable, (e) => this.updateCameraTarget(e));
+                    }
+                    box = this.boxFromCameraTarget(this.plugin.canvas3d!.camera.getSnapshot(), true);
+                    break;
                 case 'cell':
                     box = this.info.kind === 'x-ray'
                         ? this.data.structure.boundary.box
@@ -439,6 +516,7 @@ export namespace VolumeStreaming {
 
         getDescription() {
             if (this.params.entry.params.view.name === 'selection-box') return 'Selection';
+            if (this.params.entry.params.view.name === 'camera-target') return 'Camera';
             if (this.params.entry.params.view.name === 'box') return 'Static Box';
             if (this.params.entry.params.view.name === 'cell') return 'Cell';
             return '';
@@ -449,6 +527,7 @@ export namespace VolumeStreaming {
 
             this.infoMap = new Map<string, VolumeServerInfo.EntryData>();
             this.data.entries.forEach(info => this.infoMap.set(info.dataId, info));
+            this.updateQueue = new SingleAsyncQueue();
         }
     }
-}
\ No newline at end of file
+}
diff --git a/src/mol-plugin/behavior/dynamic/volume-streaming/transformers.ts b/src/mol-plugin/behavior/dynamic/volume-streaming/transformers.ts
index af1c49d98d59c0e2adf0d4ffd9c035f441866688..d4285669051f60b09d99650d6ab4cd991ad0725f 100644
--- a/src/mol-plugin/behavior/dynamic/volume-streaming/transformers.ts
+++ b/src/mol-plugin/behavior/dynamic/volume-streaming/transformers.ts
@@ -1,8 +1,9 @@
 /**
- * Copyright (c) 2019-2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2019-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author David Sehnal <david.sehnal@gmail.com>
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
+ * @author Adam Midlik <midlik@gmail.com>
  */
 
 import { PluginStateObject as SO, PluginStateTransform } from '../../../../mol-plugin-state/objects';
@@ -219,6 +220,7 @@ const CreateVolumeStreamingBehavior = PluginStateTransform.BuiltIn({
     canAutoUpdate: ({ oldParams, newParams }) => {
         return oldParams.entry.params.view === newParams.entry.params.view
             || newParams.entry.params.view.name === 'selection-box'
+            || newParams.entry.params.view.name === 'camera-target'
             || newParams.entry.params.view.name === 'off';
     },
     apply: ({ a, params }, plugin: PluginContext) => Task.create('Volume streaming', async _ => {
diff --git a/src/mol-plugin/config.ts b/src/mol-plugin/config.ts
index b70305fb7f6773cc470aca8bc9733a15db101d87..e087c3468e04b818d23357059b899aa9eb031db9 100644
--- a/src/mol-plugin/config.ts
+++ b/src/mol-plugin/config.ts
@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2020-2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2020-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author David Sehnal <david.sehnal@gmail.com>
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
@@ -12,6 +12,7 @@ import { EmdbDownloadProvider } from '../mol-plugin-state/actions/volume';
 import { StructureRepresentationPresetProvider } from '../mol-plugin-state/builder/structure/representation-preset';
 import { PluginFeatureDetection } from './features';
 import { SaccharideCompIdMapType } from '../mol-model/structure/structure/carbohydrates/constants';
+import { BackgroundProps } from '../mol-canvas3d/passes/background';
 
 export class PluginConfigItem<T = any> {
     toString() { return this.key; }
@@ -30,7 +31,7 @@ export const PluginConfig = {
         PixelScale: item('plugin-config.pixel-scale', 1),
         PickScale: item('plugin-config.pick-scale', 0.25),
         PickPadding: item('plugin-config.pick-padding', 3),
-        EnableWboit: item('plugin-config.enable-wboit', PluginFeatureDetection.wboit),
+        EnableWboit: item('plugin-config.enable-wboit', true),
         // as of Oct 1 2021, WebGL 2 doesn't work on iOS 15.
         // TODO: check back in a few weeks to see if it was fixed
         PreferWebGl1: item('plugin-config.prefer-webgl1', PluginFeatureDetection.preferWebGl1),
@@ -65,6 +66,9 @@ export const PluginConfig = {
         DefaultRepresentationPreset: item<string>('structure.default-representation-preset', 'auto'),
         DefaultRepresentationPresetParams: item<StructureRepresentationPresetProvider.CommonParams>('structure.default-representation-preset-params', { }),
         SaccharideCompIdMapType: item<SaccharideCompIdMapType>('structure.saccharide-comp-id-map-type', 'default'),
+    },
+    Background: {
+        Styles: item<[BackgroundProps, string][]>('background.styles', []),
     }
 };
 
diff --git a/src/mol-plugin/context.ts b/src/mol-plugin/context.ts
index 29ec1c03d65a88bbff9c669fd86e3eed0a5901b4..405087080f0c118dece7b9c2ec541134ddcf23d0 100644
--- a/src/mol-plugin/context.ts
+++ b/src/mol-plugin/context.ts
@@ -201,7 +201,7 @@ export class PluginContext {
                 const pickPadding = this.config.get(PluginConfig.General.PickPadding) ?? 1;
                 const enableWboit = this.config.get(PluginConfig.General.EnableWboit) || false;
                 const preferWebGl1 = this.config.get(PluginConfig.General.PreferWebGl1) || false;
-                (this.canvas3dContext as Canvas3DContext) = Canvas3DContext.fromCanvas(canvas, { antialias, preserveDrawingBuffer, pixelScale, pickScale, pickPadding, enableWboit, preferWebGl1 });
+                (this.canvas3dContext as Canvas3DContext) = Canvas3DContext.fromCanvas(canvas, this.managers.asset, { antialias, preserveDrawingBuffer, pixelScale, pickScale, pickPadding, enableWboit, preferWebGl1 });
             }
             (this.canvas3d as Canvas3D) = Canvas3D.create(this.canvas3dContext!);
             this.canvas3dInit.next(true);
diff --git a/src/mol-plugin/features.ts b/src/mol-plugin/features.ts
index ad04c71c6287bbae2ca4f8439632503403ea3b71..6a1c06d2206b2329e8f883acb0a8aec9377f08b7 100644
--- a/src/mol-plugin/features.ts
+++ b/src/mol-plugin/features.ts
@@ -1,7 +1,8 @@
 /**
- * Copyright (c) 2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2021-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author David Sehnal <david.sehnal@gmail.com>
+ * @author Alexander Rose <alexander.rose@weirdbyte.de>
  */
 
 export const PluginFeatureDetection = {
@@ -13,7 +14,7 @@ export const PluginFeatureDetection = {
         const unpportedSafariVersions = [
             'Version/15.1 Safari',
             'Version/15.2 Safari',
-            'Version/15.3 Safari'
+            'Version/15.3 Safari',
         ];
         if (unpportedSafariVersions.some(v => navigator.userAgent.indexOf(v) > 0)) {
             return true;
@@ -28,10 +29,4 @@ export const PluginFeatureDetection = {
         const isTouchScreen = navigator.maxTouchPoints >= 4; // true for iOS 13 (and hopefully beyond)
         return !(window as any).MSStream && (isIOS || (isAppleDevice && isTouchScreen));
     },
-    get wboit() {
-        if (typeof navigator === 'undefined' || typeof window === 'undefined') return true;
-
-        // disable Wboit in Safari 15
-        return !/Version\/15.\d Safari/.test(navigator.userAgent);
-    }
 };
\ No newline at end of file
diff --git a/src/mol-plugin/util/viewport-screenshot.ts b/src/mol-plugin/util/viewport-screenshot.ts
index 261ae3708d95a5c428b162b35ff69242d306c30c..97368fde642536abe397a736829216e82cbee236 100644
--- a/src/mol-plugin/util/viewport-screenshot.ts
+++ b/src/mol-plugin/util/viewport-screenshot.ts
@@ -309,7 +309,9 @@ class ViewportScreenshotHelper extends PluginComponent {
         if (width <= 0 || height <= 0) return;
 
         await ctx.update('Rendering image...');
-        const imageData = this.imagePass.getImageData(width, height, viewport);
+        const pass = this.imagePass;
+        await pass.updateBackground();
+        const imageData = pass.getImageData(width, height, viewport);
 
         await ctx.update('Encoding image...');
         const canvas = this.canvas;
diff --git a/src/mol-util/binding.ts b/src/mol-util/binding.ts
index 3ca2e97da24ad2e4f94087fb2aef71ab9b8c5d80..252de33a3bdd563565fe216d7e704486c9740d51 100644
--- a/src/mol-util/binding.ts
+++ b/src/mol-util/binding.ts
@@ -24,6 +24,10 @@ namespace Binding {
         return { triggers, action, description };
     }
 
+    export function isBinding(x: any): x is Binding {
+        return !!x && Array.isArray(x.triggers) && typeof x.action === 'string';
+    }
+
     export const Empty: Binding = { triggers: [], action: '', description: '' };
     export function isEmpty(binding: Binding) {
         return binding.triggers.length === 0 ||
diff --git a/src/mol-util/single-async-queue.ts b/src/mol-util/single-async-queue.ts
new file mode 100644
index 0000000000000000000000000000000000000000..be62698d1af38e9c140324e9784b21530f051749
--- /dev/null
+++ b/src/mol-util/single-async-queue.ts
@@ -0,0 +1,41 @@
+/**
+ * Copyright (c) 2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Adam Midlik <midlik@gmail.com>
+ */
+
+
+/** Job queue that allows at most one running and one pending job.
+ * A newly enqueued job will cancel any other pending jobs. */
+export class SingleAsyncQueue {
+    private isRunning: boolean;
+    private queue: { id: number, func: () => any }[];
+    private counter: number;
+    private log: boolean;
+    constructor(log: boolean = false) {
+        this.isRunning = false;
+        this.queue = [];
+        this.counter = 0;
+        this.log = log;
+    }
+    enqueue(job: () => any) {
+        if (this.log) console.log('SingleAsyncQueue enqueue', this.counter);
+        this.queue[0] = { id: this.counter, func: job };
+        this.counter++;
+        this.run(); // do not await
+    }
+    private async run() {
+        if (this.isRunning) return;
+        const job = this.queue.pop();
+        if (!job) return;
+        this.isRunning = true;
+        try {
+            if (this.log) console.log('SingleAsyncQueue run', job.id);
+            await job.func();
+            if (this.log) console.log('SingleAsyncQueue complete', job.id);
+        } finally {
+            this.isRunning = false;
+            this.run();
+        }
+    }
+}
diff --git a/src/tests/browser/marching-cubes.ts b/src/tests/browser/marching-cubes.ts
index 5f585b77984a400c5b3ceaa0979db8dbe1ec8a52..b9ef65cb60e415ff238cd5fe8724cebc13df73b5 100644
--- a/src/tests/browser/marching-cubes.ts
+++ b/src/tests/browser/marching-cubes.ts
@@ -22,6 +22,7 @@ import { Representation } from '../../mol-repr/representation';
 import { computeMarchingCubesMesh } from '../../mol-geo/util/marching-cubes/algorithm';
 import { Mesh } from '../../mol-geo/geometry/mesh/mesh';
 import { ParamDefinition as PD } from '../../mol-util/param-definition';
+import { AssetManager } from '../../mol-util/assets';
 
 const parent = document.getElementById('app')!;
 parent.style.width = '100%';
@@ -31,7 +32,9 @@ const canvas = document.createElement('canvas');
 parent.appendChild(canvas);
 resizeCanvas(canvas, parent);
 
-const canvas3d = Canvas3D.create(Canvas3DContext.fromCanvas(canvas), PD.merge(Canvas3DParams, PD.getDefaultValues(Canvas3DParams), {
+const assetManager = new AssetManager();
+
+const canvas3d = Canvas3D.create(Canvas3DContext.fromCanvas(canvas, assetManager), PD.merge(Canvas3DParams, PD.getDefaultValues(Canvas3DParams), {
     renderer: { backgroundColor: ColorNames.white },
     camera: { mode: 'orthographic' }
 }));
diff --git a/src/tests/browser/render-lines.ts b/src/tests/browser/render-lines.ts
index d4996f777d1ffcb2865e951c41935c296d7aa03d..137ca2a69f9d71023fd9d3f5d4ba3af469b6bcf6 100644
--- a/src/tests/browser/render-lines.ts
+++ b/src/tests/browser/render-lines.ts
@@ -15,6 +15,7 @@ import { Color } from '../../mol-util/color';
 import { createRenderObject } from '../../mol-gl/render-object';
 import { Representation } from '../../mol-repr/representation';
 import { ParamDefinition } from '../../mol-util/param-definition';
+import { AssetManager } from '../../mol-util/assets';
 
 const parent = document.getElementById('app')!;
 parent.style.width = '100%';
@@ -24,7 +25,9 @@ const canvas = document.createElement('canvas');
 parent.appendChild(canvas);
 resizeCanvas(canvas, parent);
 
-const canvas3d = Canvas3D.create(Canvas3DContext.fromCanvas(canvas));
+const assetManager = new AssetManager();
+
+const canvas3d = Canvas3D.create(Canvas3DContext.fromCanvas(canvas, assetManager));
 canvas3d.animate();
 
 function linesRepr() {
diff --git a/src/tests/browser/render-mesh.ts b/src/tests/browser/render-mesh.ts
index 12861bad246a92f054407259eef86686d45cfbde..e639b931e7dc826a5f7ab86808ed74cc8da1c8ce 100644
--- a/src/tests/browser/render-mesh.ts
+++ b/src/tests/browser/render-mesh.ts
@@ -17,6 +17,7 @@ import { createRenderObject } from '../../mol-gl/render-object';
 import { Representation } from '../../mol-repr/representation';
 import { Torus } from '../../mol-geo/primitive/torus';
 import { ParamDefinition } from '../../mol-util/param-definition';
+import { AssetManager } from '../../mol-util/assets';
 
 const parent = document.getElementById('app')!;
 parent.style.width = '100%';
@@ -26,7 +27,9 @@ const canvas = document.createElement('canvas');
 parent.appendChild(canvas);
 resizeCanvas(canvas, parent);
 
-const canvas3d = Canvas3D.create(Canvas3DContext.fromCanvas(canvas));
+const assetManager = new AssetManager();
+
+const canvas3d = Canvas3D.create(Canvas3DContext.fromCanvas(canvas, assetManager));
 canvas3d.animate();
 
 function meshRepr() {
diff --git a/src/tests/browser/render-shape.ts b/src/tests/browser/render-shape.ts
index cf83c1c9a4f8518ecf84416e804b07e594ee5552..184d273939372d42893fa6086f06b32792266cd0 100644
--- a/src/tests/browser/render-shape.ts
+++ b/src/tests/browser/render-shape.ts
@@ -19,6 +19,7 @@ import { Sphere } from '../../mol-geo/primitive/sphere';
 import { ColorNames } from '../../mol-util/color/names';
 import { Shape } from '../../mol-model/shape';
 import { ShapeRepresentation } from '../../mol-repr/shape/representation';
+import { AssetManager } from '../../mol-util/assets';
 
 const parent = document.getElementById('app')!;
 parent.style.width = '100%';
@@ -28,6 +29,8 @@ const canvas = document.createElement('canvas');
 parent.appendChild(canvas);
 resizeCanvas(canvas, parent);
 
+const assetManager = new AssetManager();
+
 const info = document.createElement('div');
 info.style.position = 'absolute';
 info.style.fontFamily = 'sans-serif';
@@ -38,7 +41,7 @@ info.style.color = 'white';
 parent.appendChild(info);
 
 let prevReprLoci = Representation.Loci.Empty;
-const canvas3d = Canvas3D.create(Canvas3DContext.fromCanvas(canvas));
+const canvas3d = Canvas3D.create(Canvas3DContext.fromCanvas(canvas, assetManager));
 canvas3d.animate();
 canvas3d.input.move.subscribe(({ x, y }) => {
     const pickingId = canvas3d.identify(x, y)?.id;
diff --git a/src/tests/browser/render-spheres.ts b/src/tests/browser/render-spheres.ts
index 439429fe35d6efec99e0ec225ab266b48e98b8a0..ed4e92ae278411bd11b155dbb3cc23fd38fd6b67 100644
--- a/src/tests/browser/render-spheres.ts
+++ b/src/tests/browser/render-spheres.ts
@@ -13,6 +13,7 @@ import { Color } from '../../mol-util/color';
 import { createRenderObject } from '../../mol-gl/render-object';
 import { Representation } from '../../mol-repr/representation';
 import { ParamDefinition } from '../../mol-util/param-definition';
+import { AssetManager } from '../../mol-util/assets';
 
 const parent = document.getElementById('app')!;
 parent.style.width = '100%';
@@ -22,7 +23,9 @@ const canvas = document.createElement('canvas');
 parent.appendChild(canvas);
 resizeCanvas(canvas, parent);
 
-const canvas3d = Canvas3D.create(Canvas3DContext.fromCanvas(canvas));
+const assetManager = new AssetManager();
+
+const canvas3d = Canvas3D.create(Canvas3DContext.fromCanvas(canvas, assetManager));
 canvas3d.animate();
 
 function spheresRepr() {
diff --git a/src/tests/browser/render-structure.ts b/src/tests/browser/render-structure.ts
index 07e23ee4918d7a8698ceb01cc843ec62a4ceb614..634ccd8adeb7366d499ef77d0afffc6444961317 100644
--- a/src/tests/browser/render-structure.ts
+++ b/src/tests/browser/render-structure.ts
@@ -37,7 +37,9 @@ const canvas = document.createElement('canvas');
 parent.appendChild(canvas);
 resizeCanvas(canvas, parent);
 
-const canvas3d = Canvas3D.create(Canvas3DContext.fromCanvas(canvas));
+const assetManager = new AssetManager();
+
+const canvas3d = Canvas3D.create(Canvas3DContext.fromCanvas(canvas, assetManager));
 canvas3d.animate();
 
 const info = document.createElement('div');
@@ -123,7 +125,7 @@ function getMembraneOrientationRepr() {
 }
 
 async function init() {
-    const ctx = { runtime: SyncRuntimeContext, assetManager: new AssetManager() };
+    const ctx = { runtime: SyncRuntimeContext, assetManager };
 
     const cif = await downloadFromPdb('3pqr');
     const models = await getModels(cif);
diff --git a/src/tests/browser/render-text.ts b/src/tests/browser/render-text.ts
index c25a45fa195c9d1e3d8eaf1f0b32db0a0058bcbe..b1b0a33a09346f88270be7bf3138ed366b0e4ece 100644
--- a/src/tests/browser/render-text.ts
+++ b/src/tests/browser/render-text.ts
@@ -15,6 +15,7 @@ import { SpheresBuilder } from '../../mol-geo/geometry/spheres/spheres-builder';
 import { createRenderObject } from '../../mol-gl/render-object';
 import { Spheres } from '../../mol-geo/geometry/spheres/spheres';
 import { resizeCanvas } from '../../mol-canvas3d/util';
+import { AssetManager } from '../../mol-util/assets';
 
 const parent = document.getElementById('app')!;
 parent.style.width = '100%';
@@ -24,7 +25,9 @@ const canvas = document.createElement('canvas');
 parent.appendChild(canvas);
 resizeCanvas(canvas, parent);
 
-const canvas3d = Canvas3D.create(Canvas3DContext.fromCanvas(canvas));
+const assetManager = new AssetManager();
+
+const canvas3d = Canvas3D.create(Canvas3DContext.fromCanvas(canvas, assetManager));
 canvas3d.animate();
 
 function textRepr() {
diff --git a/webpack.config.common.js b/webpack.config.common.js
index 491eb8d551226831574049bcd850af99c1b939f7..380e1cc3e871daf21e71553e7b0efe452aacd3cc 100644
--- a/webpack.config.common.js
+++ b/webpack.config.common.js
@@ -30,7 +30,11 @@ const sharedConfig = {
                     { loader: 'css-loader', options: { sourceMap: false } },
                     { loader: 'sass-loader', options: { sourceMap: false } },
                 ]
-            }
+            },
+            {
+                test: /\.(jpg)$/i,
+                type: 'asset/resource',
+            },
         ]
     },
     plugins: [
@@ -76,7 +80,7 @@ function createEntry(src, outFolder, outFilename, isNode) {
 function createEntryPoint(name, dir, out, library) {
     return {
         entry: path.resolve(__dirname, `lib/${dir}/${name}.js`),
-        output: { filename: `${library || name}.js`, path: path.resolve(__dirname, `build/${out}`), library: library || out, libraryTarget: 'umd' },
+        output: { filename: `${library || name}.js`, path: path.resolve(__dirname, `build/${out}`), library: library || out, libraryTarget: 'umd', assetModuleFilename: 'images/[hash][ext][query]', 'publicPath': '' },
         ...sharedConfig
     };
 }