Переглянути джерело

Merge branch 'master' into safari-surf-fix

Alexander Rose 2 роки тому
батько
коміт
b3e79544ad
60 змінених файлів з 1230 додано та 188 видалено
  1. 20 0
      CHANGELOG.md
  2. 2 2
      package.json
  3. 2 0
      src/apps/viewer/app.ts
  4. BIN
      src/extensions/backgrounds/images/cells.jpg
  5. 90 0
      src/extensions/backgrounds/index.ts
  6. BIN
      src/extensions/backgrounds/skyboxes/nebula/nebula_back6.jpg
  7. BIN
      src/extensions/backgrounds/skyboxes/nebula/nebula_bottom4.jpg
  8. BIN
      src/extensions/backgrounds/skyboxes/nebula/nebula_front5.jpg
  9. BIN
      src/extensions/backgrounds/skyboxes/nebula/nebula_left2.jpg
  10. BIN
      src/extensions/backgrounds/skyboxes/nebula/nebula_right1.jpg
  11. BIN
      src/extensions/backgrounds/skyboxes/nebula/nebula_top3.jpg
  12. 10 0
      src/extensions/backgrounds/typings.d.ts
  13. 1 0
      src/extensions/mp4-export/encoder.ts
  14. 41 9
      src/mol-canvas3d/canvas3d.ts
  15. 461 0
      src/mol-canvas3d/passes/background.ts
  16. 49 45
      src/mol-canvas3d/passes/draw.ts
  17. 2 2
      src/mol-canvas3d/passes/fxaa.ts
  18. 12 3
      src/mol-canvas3d/passes/image.ts
  19. 4 4
      src/mol-canvas3d/passes/marking.ts
  20. 10 10
      src/mol-canvas3d/passes/multi-sample.ts
  21. 3 2
      src/mol-canvas3d/passes/passes.ts
  22. 54 24
      src/mol-canvas3d/passes/postprocessing.ts
  23. 2 2
      src/mol-canvas3d/passes/smaa.ts
  24. 6 6
      src/mol-geo/geometry/texture-mesh/color-smoothing.ts
  25. 2 2
      src/mol-gl/compute/grid3d.ts
  26. 7 7
      src/mol-gl/compute/histogram-pyramid/reduction.ts
  27. 2 2
      src/mol-gl/compute/histogram-pyramid/sum.ts
  28. 4 4
      src/mol-gl/compute/marching-cubes/active-voxels.ts
  29. 2 2
      src/mol-gl/compute/marching-cubes/isosurface.ts
  30. 2 2
      src/mol-gl/compute/util.ts
  31. 13 9
      src/mol-gl/renderer.ts
  32. 30 2
      src/mol-gl/scene.ts
  33. 2 0
      src/mol-gl/shader-code.ts
  34. 85 0
      src/mol-gl/shader/background.frag.ts
  35. 12 0
      src/mol-gl/shader/background.vert.ts
  36. 6 5
      src/mol-gl/webgl/context.ts
  37. 3 3
      src/mol-gl/webgl/render-item.ts
  38. 10 2
      src/mol-gl/webgl/resources.ts
  39. 29 0
      src/mol-gl/webgl/state.ts
  40. 119 1
      src/mol-gl/webgl/texture.ts
  41. 6 6
      src/mol-math/geometry/gaussian-density/gpu.ts
  42. 11 3
      src/mol-math/geometry/primitives/box3d.ts
  43. 4 1
      src/mol-model-formats/structure/mol.ts
  44. 4 1
      src/mol-model-formats/structure/mol2.ts
  45. 1 1
      src/mol-model-props/common/custom-element-property.ts
  46. 32 4
      src/mol-model/structure/structure/structure.ts
  47. 14 6
      src/mol-model/structure/structure/unit/bonds/inter-compute.ts
  48. 4 1
      src/mol-model/structure/structure/unit/bonds/intra-compute.ts
  49. 14 2
      src/mol-plugin-ui/viewport/simple-settings.tsx
  50. 5 1
      src/mol-plugin/config.ts
  51. 1 1
      src/mol-plugin/context.ts
  52. 3 1
      src/mol-plugin/util/viewport-screenshot.ts
  53. 4 1
      src/tests/browser/marching-cubes.ts
  54. 4 1
      src/tests/browser/render-lines.ts
  55. 4 1
      src/tests/browser/render-mesh.ts
  56. 4 1
      src/tests/browser/render-shape.ts
  57. 4 1
      src/tests/browser/render-spheres.ts
  58. 4 2
      src/tests/browser/render-structure.ts
  59. 4 1
      src/tests/browser/render-text.ts
  60. 6 2
      webpack.config.common.js

+ 20 - 0
CHANGELOG.md

@@ -6,6 +6,8 @@ Note that since we don't clearly distinguish between a public and private interf
 
 ## [Unreleased]
 
+- 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
@@ -13,8 +15,26 @@ Note that since we don't clearly distinguish between a public and private interf
     - 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
 

+ 2 - 2
package.json

@@ -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",

+ 2 - 0
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),

BIN
src/extensions/backgrounds/images/cells.jpg


+ 90 - 0
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: () => ({ })
+});

BIN
src/extensions/backgrounds/skyboxes/nebula/nebula_back6.jpg


BIN
src/extensions/backgrounds/skyboxes/nebula/nebula_bottom4.jpg


BIN
src/extensions/backgrounds/skyboxes/nebula/nebula_front5.jpg


BIN
src/extensions/backgrounds/skyboxes/nebula/nebula_left2.jpg


BIN
src/extensions/backgrounds/skyboxes/nebula/nebula_right1.jpg


BIN
src/extensions/backgrounds/skyboxes/nebula/nebula_top3.jpg


+ 10 - 0
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;
+}

+ 1 - 0
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;

+ 41 - 9
src/mol-canvas3d/canvas3d.ts

@@ -40,6 +40,8 @@ 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';
 
 export const Canvas3DParams = {
     camera: PD.Group({
@@ -49,6 +51,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 +81,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 +110,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 +129,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 +144,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 +197,7 @@ namespace Canvas3DContext {
             attribs: a,
             contextLost,
             contextRestored: webgl.contextRestored,
+            assetManager,
             dispose: (options?: Partial<{ doNotForceWebGLContextLoss: boolean }>) => {
                 input.dispose();
 
@@ -278,7 +284,7 @@ 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 {
+    export function create({ webgl, input, passes, attribs, assetManager }: Canvas3DContext, props: Partial<Canvas3DProps> = {}): Canvas3D {
         const p: Canvas3DProps = { ...DefaultCanvas3DParams, ...props };
 
         const reprRenderObjects = new Map<Representation.Any, Set<GraphicsRenderObject>>();
@@ -299,11 +305,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 +326,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 +538,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 +589,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 +665,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 +673,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 +681,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 +784,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 +806,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 +831,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 +855,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[] = [];

+ 461 - 0
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);
+}

+ 49 - 45
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');
     }

+ 2 - 2
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);

+ 12 - 3
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;
 

+ 4 - 4
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) {

+ 10 - 10
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) {

+ 3 - 2
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);
     }

+ 54 - 24
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');

+ 2 - 2
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);

+ 6 - 6
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');

+ 2 - 2
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);

+ 7 - 7
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);

+ 2 - 2
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();
 

+ 4 - 4
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);

+ 2 - 2
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();
 

+ 2 - 2
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();
 

+ 13 - 9
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));

+ 30 - 2
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;
+            },
         };
     }
 }

+ 2 - 0
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

+ 85 - 0
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
+}
+`;

+ 12 - 0
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);
+}
+`;

+ 6 - 5
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);
         },
 

+ 3 - 3
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);

+ 10 - 2
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 => {

+ 29 - 0
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);
         }
     };
 }

+ 119 - 1
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 {

+ 6 - 6
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')

+ 11 - 3
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]
+        );
     }
 }
 

+ 4 - 1
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);
     }
 

+ 4 - 1
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);

+ 1 - 1
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)

+ 32 - 4
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);

+ 14 - 6
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;

+ 4 - 1
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>();

+ 14 - 2
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,

+ 5 - 1
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; }
@@ -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', []),
     }
 };
 

+ 1 - 1
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);

+ 3 - 1
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;

+ 4 - 1
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' }
 }));

+ 4 - 1
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() {

+ 4 - 1
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() {

+ 4 - 1
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;

+ 4 - 1
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() {

+ 4 - 2
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);

+ 4 - 1
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() {

+ 6 - 2
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
     };
 }