diff --git a/src/apps/viewer/index.ts b/src/apps/viewer/index.ts
index 58cf0548bab197926170c5d74ad16e1f50c09bc2..e210dd26c2afd72f3b95989ad106110ad4eb43d5 100644
--- a/src/apps/viewer/index.ts
+++ b/src/apps/viewer/index.ts
@@ -59,6 +59,7 @@ const DefaultViewerOptions = {
     layoutShowLog: true,
     layoutShowLeftPanel: true,
     disableAntialiasing: false,
+    pixelScale: 1,
 
     viewportShowExpand: PluginConfig.Viewport.ShowExpand.defaultValue,
     viewportShowControls: PluginConfig.Viewport.ShowControls.defaultValue,
@@ -106,6 +107,7 @@ export class Viewer {
             },
             config: [
                 [PluginConfig.General.DisableAntialiasing, o.disableAntialiasing],
+                [PluginConfig.General.PixelScale, o.pixelScale],
                 [PluginConfig.Viewport.ShowExpand, o.viewportShowExpand],
                 [PluginConfig.Viewport.ShowControls, o.viewportShowControls],
                 [PluginConfig.Viewport.ShowSettings, o.viewportShowSettings],
diff --git a/src/mol-canvas3d/camera.ts b/src/mol-canvas3d/camera.ts
index 94eef75267cabead1ad52e4d42849b3bb6d2c24a..0fadca2a5dd7fbadcb769aff4d2c04006beb5a65 100644
--- a/src/mol-canvas3d/camera.ts
+++ b/src/mol-canvas3d/camera.ts
@@ -18,6 +18,12 @@ class Camera {
     readonly projectionView: Mat4 = Mat4.identity();
     readonly inverseProjectionView: Mat4 = Mat4.identity();
 
+    private pixelScale: number
+    get pixelRatio () {
+        const dpr = (typeof window !== 'undefined') ? window.devicePixelRatio : 1;
+        return dpr * this.pixelScale;
+    }
+
     readonly viewport: Viewport;
     readonly state: Readonly<Camera.Snapshot> = Camera.createDefaultSnapshot();
     readonly viewOffset: Camera.ViewOffset = {
@@ -126,8 +132,9 @@ class Camera {
         return cameraUnproject(out, point, this.viewport, this.inverseProjectionView);
     }
 
-    constructor(state?: Partial<Camera.Snapshot>, viewport = Viewport.create(-1, -1, 1, 1)) {
+    constructor(state?: Partial<Camera.Snapshot>, viewport = Viewport.create(0, 0, 128, 128), props: Partial<{ pixelScale: number }> = {}) {
         this.viewport = viewport;
+        this.pixelScale = props.pixelScale || 1;
         Camera.copySnapshot(this.state, state);
     }
 }
diff --git a/src/mol-canvas3d/canvas3d.ts b/src/mol-canvas3d/canvas3d.ts
index 979554b5e58a7dbfb381f3f64d0074b9522a5be1..674d939e7a2b9429e1c567b514f18ac08b55b249 100644
--- a/src/mol-canvas3d/canvas3d.ts
+++ b/src/mol-canvas3d/canvas3d.ts
@@ -51,6 +51,15 @@ export const Canvas3DParams = {
         radius: PD.Numeric(100, { min: 0, max: 99, step: 1 }, { label: 'Clipping', description: 'How much of the scene to show.' }),
         far: PD.Boolean(true, { description: 'Hide scene in the distance' }),
     }, { pivot: 'radius' }),
+    viewport: PD.MappedStatic('canvas', {
+        canvas: PD.Group({}),
+        custom: PD.Group({
+            x: PD.Numeric(0),
+            y: PD.Numeric(0),
+            width: PD.Numeric(128),
+            height: PD.Numeric(128)
+        })
+    }),
 
     cameraResetDurationMs: PD.Numeric(250, { min: 0, max: 1000, step: 1 }, { description: 'The time it takes to reset the camera.' }),
     transparentBackground: PD.Boolean(false),
@@ -122,17 +131,19 @@ 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 }
 
-    export function fromCanvas(canvas: HTMLCanvasElement, props: PartialCanvas3DProps = {}, attribs: Partial<{ antialias: boolean }> = {}) {
+    export function fromCanvas(canvas: HTMLCanvasElement, props: PartialCanvas3DProps = {}, attribs: Partial<{ antialias: boolean, pixelScale: number }> = {}) {
         const gl = getGLContext(canvas, {
             alpha: true,
             antialias: attribs.antialias ?? true,
             depth: true,
-            preserveDrawingBuffer: false,
+            preserveDrawingBuffer: true,
             premultipliedAlpha: false,
         });
         if (gl === null) throw new Error('Could not create a WebGL rendering context');
-        const input = InputObserver.fromElement(canvas);
-        const webgl = createContext(gl);
+
+        const { pixelScale } = attribs;
+        const input = InputObserver.fromElement(canvas, { pixelScale });
+        const webgl = createContext(gl, { pixelScale });
 
         if (isDebugMode) {
             const loseContextExt = gl.getExtension('WEBGL_lose_context');
@@ -167,10 +178,10 @@ namespace Canvas3D {
             if (isDebugMode) console.log('context restored');
         }, false);
 
-        return Canvas3D.create(webgl, input, props);
+        return create(webgl, input, props, { pixelScale });
     }
 
-    export function create(webgl: WebGLContext, input: InputObserver, props: PartialCanvas3DProps = {}, attribs: Partial<{ pickScale: number }> = {}): Canvas3D {
+    export function create(webgl: WebGLContext, input: InputObserver, props: PartialCanvas3DProps = {}, attribs: Partial<{ pickScale: number, pixelScale: number }> = {}): Canvas3D {
         const p = { ...DefaultCanvas3DParams, ...props };
 
         const reprRenderObjects = new Map<Representation.Any, Set<GraphicsRenderObject>>();
@@ -182,8 +193,11 @@ namespace Canvas3D {
 
         const { gl, contextRestored } = webgl;
 
-        let width = gl.drawingBufferWidth;
-        let height = gl.drawingBufferHeight;
+        let x = 0;
+        let y = 0;
+        let width = 128;
+        let height = 128;
+        updateViewport();
 
         const scene = Scene.create(webgl);
 
@@ -192,7 +206,7 @@ namespace Canvas3D {
             mode: p.camera.mode,
             fog: p.cameraFog.name === 'on' ? p.cameraFog.params.intensity : 0,
             clipFar: p.cameraClipping.far
-        });
+        }, { x, y, width, height }, { pixelScale: attribs.pixelScale });
 
         const controls = TrackballControls.create(input, camera, p.trackball);
         const renderer = Renderer.create(webgl, p.renderer);
@@ -251,15 +265,18 @@ namespace Canvas3D {
 
         function render(force: boolean) {
             if (webgl.isContextLost) return false;
+            if (x > gl.drawingBufferWidth || x + width < 0 ||
+                y > gl.drawingBufferHeight || y + height < 0
+            ) return false;
 
             let didRender = false;
             controls.update(currentTime);
-            Viewport.set(camera.viewport, 0, 0, width, height);
+            Viewport.set(camera.viewport, x, y, width, height);
             const cameraChanged = camera.update();
             const multiSampleChanged = multiSample.update(force || cameraChanged);
 
             if (force || cameraChanged || multiSampleChanged) {
-                renderer.setViewport(0, 0, width, height);
+                renderer.setViewport(x, y, width, height);
                 if (multiSample.enabled) {
                     multiSample.render(true, p.transparentBackground);
                 } else {
@@ -472,6 +489,7 @@ namespace Canvas3D {
                 cameraClipping: { far: camera.state.clipFar, radius },
                 cameraResetDurationMs: p.cameraResetDurationMs,
                 transparentBackground: p.transparentBackground,
+                viewport: p.viewport,
 
                 postprocessing: { ...postprocessing.props },
                 multiSample: { ...multiSample.props },
@@ -573,6 +591,10 @@ namespace Canvas3D {
                 if (props.camera?.manualReset !== undefined) p.camera.manualReset = props.camera.manualReset;
                 if (props.cameraResetDurationMs !== undefined) p.cameraResetDurationMs = props.cameraResetDurationMs;
                 if (props.transparentBackground !== undefined) p.transparentBackground = props.transparentBackground;
+                if (props.viewport !== undefined) {
+                    p.viewport = props.viewport;
+                    handleResize();
+                }
 
                 if (props.postprocessing) postprocessing.setProps(props.postprocessing);
                 if (props.multiSample) multiSample.setProps(props.multiSample);
@@ -611,13 +633,26 @@ namespace Canvas3D {
             }
         };
 
-        function handleResize() {
+        function updateViewport() {
+            if (p.viewport.name === 'canvas') {
+                x = 0;
+                y = 0;
             width = gl.drawingBufferWidth;
             height = gl.drawingBufferHeight;
+            } else {
+                x = p.viewport.params.x * webgl.pixelRatio;
+                y = p.viewport.params.y * webgl.pixelRatio;
+                width = p.viewport.params.width * webgl.pixelRatio;
+                height = p.viewport.params.height * webgl.pixelRatio;
+            }
+        }
+
+        function handleResize() {
+            updateViewport();
 
-            renderer.setViewport(0, 0, width, height);
-            Viewport.set(camera.viewport, 0, 0, width, height);
-            Viewport.set(controls.viewport, 0, 0, width, height);
+            renderer.setViewport(x, y, width, height);
+            Viewport.set(camera.viewport, x, y, width, height);
+            Viewport.set(controls.viewport, x, y, width, height);
 
             drawPass.setSize(width, height);
             pickPass.setSize(width, height);
diff --git a/src/mol-canvas3d/controls/object.ts b/src/mol-canvas3d/controls/object.ts
index ddc62a64c34ad3085d174f71926c31a4e9d54f72..db9eb23ec5cc784f9ae336ac4b7992210db3c26c 100644
--- a/src/mol-canvas3d/controls/object.ts
+++ b/src/mol-canvas3d/controls/object.ts
@@ -44,9 +44,8 @@ export namespace ObjectControls {
         const height = 2 * Math.tan(camera.state.fov / 2) * dist;
         const zoom = camera.viewport.height / height;
 
-        const dpr = window.devicePixelRatio;
-        panMouseChange[0] *= (1 / zoom) * camera.viewport.width * dpr;
-        panMouseChange[1] *= (1 / zoom) * camera.viewport.height * dpr;
+        panMouseChange[0] *= (1 / zoom) * camera.viewport.width * camera.pixelRatio;
+        panMouseChange[1] *= (1 / zoom) * camera.viewport.height * camera.pixelRatio;
 
         Vec3.cross(panOffset, Vec3.copy(panOffset, eye), camera.up);
         Vec3.setMagnitude(panOffset, panOffset, panMouseChange[0]);
diff --git a/src/mol-canvas3d/controls/trackball.ts b/src/mol-canvas3d/controls/trackball.ts
index f93abfda670ad9bbc959f451025199eb6652ecaf..ebc56f695018556cb5f7b1841dbf31d20b6c2477 100644
--- a/src/mol-canvas3d/controls/trackball.ts
+++ b/src/mol-canvas3d/controls/trackball.ts
@@ -227,7 +227,7 @@ namespace TrackballControls {
             Vec2.sub(panMouseChange, Vec2.copy(panMouseChange, _panEnd), _panStart);
 
             if (Vec2.squaredMagnitude(panMouseChange)) {
-                const factor = window.devicePixelRatio * p.panSpeed;
+                const factor = input.pixelRatio * p.panSpeed;
                 panMouseChange[0] *= (1 / camera.zoom) * camera.viewport.width * factor;
                 panMouseChange[1] *= (1 / camera.zoom) * camera.viewport.height * factor;
 
@@ -271,6 +271,17 @@ namespace TrackballControls {
             }
         }
 
+        function outsideViewport(x: number, y: number) {
+            x *= input.pixelRatio;
+            y *= input.pixelRatio;
+            return (
+                x > viewport.x + viewport.width ||
+                input.height - y > viewport.y + viewport.height ||
+                x < viewport.x ||
+                input.height - y < viewport.y
+            );
+        }
+
         let lastUpdated = -1;
         /** Update the object's position, direction and up vectors */
         function update(t: number) {
@@ -307,7 +318,12 @@ namespace TrackballControls {
 
         // listeners
 
-        function onDrag({ pageX, pageY, buttons, modifiers, isStart }: DragInput) {
+        function onDrag({ x, y, pageX, pageY, buttons, modifiers, isStart }: DragInput) {
+            const isOutside = outsideViewport(x, y);
+
+            if (isStart && isOutside) return;
+            if (!isStart && !_isInteracting) return;
+
             _isInteracting = true;
 
             const dragRotate = Binding.match(p.bindings.dragRotate, buttons, modifiers);
@@ -358,7 +374,9 @@ namespace TrackballControls {
             _isInteracting = false;
         }
 
-        function onWheel({ dx, dy, dz, buttons, modifiers }: WheelInput) {
+        function onWheel({ x, y, dx, dy, dz, buttons, modifiers }: WheelInput) {
+            if (outsideViewport(x, y)) return;
+
             const delta = absMax(dx, dy, dz);
             if (Binding.match(p.bindings.scrollZoom, buttons, modifiers)) {
                 _zoomEnd[1] += delta * 0.0001;
diff --git a/src/mol-canvas3d/passes/draw.ts b/src/mol-canvas3d/passes/draw.ts
index 5a08e5f2a34dbaf6b1951d64e768be4368aa7e61..9db4b947753caf77dda5dcce9b0f365ca5dcfd42 100644
--- a/src/mol-canvas3d/passes/draw.ts
+++ b/src/mol-canvas3d/passes/draw.ts
@@ -71,9 +71,9 @@ export class DrawPass {
     private depthMerge: DepthMergeRenderable
 
     constructor(private webgl: WebGLContext, private renderer: Renderer, private scene: Scene, private camera: Camera, private debugHelper: BoundingSphereHelper, private handleHelper: HandleHelper, props: Partial<DrawPassProps> = {}) {
-        const { gl, extensions, resources } = webgl;
-        const width = gl.drawingBufferWidth;
-        const height = gl.drawingBufferHeight;
+        const { extensions, resources } = webgl;
+        const { width, height } = camera.viewport;
+
         this.colorTarget = webgl.createRenderTarget(width, height);
         this.packedDepth = !extensions.depthTexture;
 
@@ -125,16 +125,18 @@ export class DrawPass {
     }
 
     render(toDrawingBuffer: boolean, transparentBackground: boolean) {
+        const { x, y, width, height } = this.camera.viewport;
         if (toDrawingBuffer) {
             this.webgl.unbindFramebuffer();
+            this.renderer.setViewport(x, y, width, height);
         } else {
             this.colorTarget.bind();
+            this.renderer.setViewport(0, 0, width, height);
             if (!this.packedDepth) {
                 this.depthTexturePrimitives.attachFramebuffer(this.colorTarget.framebuffer, 'depth');
             }
         }
 
-        this.renderer.setViewport(0, 0, this.colorTarget.getWidth(), this.colorTarget.getHeight());
         this.renderer.render(this.scene.primitives, this.camera, 'color', true, transparentBackground, null);
 
         // do a depth pass if not rendering to drawing buffer and
diff --git a/src/mol-canvas3d/passes/multi-sample.ts b/src/mol-canvas3d/passes/multi-sample.ts
index eda78ce95f9c6442bb3a559505b2b85f9c05e150..8a8dfdb40a6a65ede6e1278550b6562da0da8ec7 100644
--- a/src/mol-canvas3d/passes/multi-sample.ts
+++ b/src/mol-canvas3d/passes/multi-sample.ts
@@ -82,11 +82,11 @@ export class MultiSamplePass {
     setSize(width: number, height: number) {
         const [w, h] = this.compose.values.uTexSize.ref.value;
         if (width !== w || height !== h) {
-        this.colorTarget.setSize(width, height);
-        this.composeTarget.setSize(width, height);
-        this.holdTarget.setSize(width, height);
-        ValueCell.update(this.compose.values.uTexSize, Vec2.set(this.compose.values.uTexSize.ref.value, width, height));
-    }
+            this.colorTarget.setSize(width, height);
+            this.composeTarget.setSize(width, height);
+            this.holdTarget.setSize(width, height);
+            ValueCell.update(this.compose.values.uTexSize, Vec2.set(this.compose.values.uTexSize.ref.value, width, height));
+        }
     }
 
     setProps(props: Partial<MultiSampleProps>) {
@@ -102,6 +102,12 @@ export class MultiSamplePass {
         }
     }
 
+    private setQuadShift(x: number, y: number) {
+        ValueCell.update(this.compose.values.uQuadShift, Vec2.set(
+            this.compose.values.uQuadShift.ref.value, x, y)
+        );
+    }
+
     private renderMultiSample(toDrawingBuffer: boolean, transparentBackground: boolean) {
         const { camera, compose, composeTarget, drawPass, postprocessing, webgl } = this;
         const { gl, state } = webgl;
@@ -120,8 +126,7 @@ export class MultiSamplePass {
         ValueCell.update(compose.values.tColor, postprocessing.enabled ? postprocessing.target.texture : drawPass.colorTarget.texture);
         compose.update();
 
-        const width = drawPass.colorTarget.getWidth();
-        const height = drawPass.colorTarget.getHeight();
+        const { x, y, width, height } = camera.viewport;
 
         // render the scene multiple times, each slightly jitter offset
         // from the last and accumulate the results.
@@ -144,7 +149,6 @@ export class MultiSamplePass {
 
             // compose rendered scene with compose target
             composeTarget.bind();
-            gl.viewport(0, 0, width, height);
             state.enable(gl.BLEND);
             state.blendEquationSeparate(gl.FUNC_ADD, gl.FUNC_ADD);
             state.blendFuncSeparate(gl.ONE, gl.ONE, gl.ONE, gl.ONE);
@@ -155,6 +159,8 @@ export class MultiSamplePass {
                 state.clearColor(0, 0, 0, 0);
                 gl.clear(gl.COLOR_BUFFER_BIT);
             }
+            this.setQuadShift(0, 0);
+            gl.viewport(0, 0, width, height);
             compose.render();
         }
 
@@ -167,7 +173,8 @@ export class MultiSamplePass {
         } else {
             this.colorTarget.bind();
         }
-        gl.viewport(0, 0, width, height);
+        this.setQuadShift(x / width, y / height);
+        gl.viewport(x, y, width, height);
         state.disable(gl.BLEND);
         compose.render();
 
@@ -192,8 +199,7 @@ export class MultiSamplePass {
             return;
         }
 
-        const width = drawPass.colorTarget.getWidth();
-        const height = drawPass.colorTarget.getHeight();
+        const { x, y, width, height } = camera.viewport;
         const sampleWeight = 1.0 / offsetList.length;
 
         if (this.sampleIndex === -1) {
@@ -205,6 +211,11 @@ export class MultiSamplePass {
 
             holdTarget.bind();
             state.disable(gl.BLEND);
+            state.disable(gl.DEPTH_TEST);
+            state.disable(gl.SCISSOR_TEST);
+            state.depthMask(false);
+            this.setQuadShift(0, 0);
+            gl.viewport(0, 0, width, height);
             compose.render();
             this.sampleIndex += 1;
         } else {
@@ -238,6 +249,8 @@ export class MultiSamplePass {
                     state.clearColor(0, 0, 0, 0);
                     gl.clear(gl.COLOR_BUFFER_BIT);
                 }
+                this.setQuadShift(0, 0);
+                gl.viewport(0, 0, width, height);
                 compose.render();
 
                 this.sampleIndex += 1;
@@ -247,10 +260,13 @@ export class MultiSamplePass {
 
         if (toDrawingBuffer) {
             webgl.unbindFramebuffer();
+            this.setQuadShift(x / width, y / height);
+            gl.viewport(x, y, width, height);
         } else {
             this.colorTarget.bind();
+            this.setQuadShift(0, 0);
+            gl.viewport(0, 0, width, height);
         }
-        gl.viewport(0, 0, width, height);
 
         const accumulationWeight = this.sampleIndex * sampleWeight;
         if (accumulationWeight > 0) {
diff --git a/src/mol-canvas3d/passes/pick.ts b/src/mol-canvas3d/passes/pick.ts
index ae87e0ae8a4b814418702bc7ef7d008724faa63d..caf6c1b9d767c2fc22c86a0f57fb8a87b322fcfe 100644
--- a/src/mol-canvas3d/passes/pick.ts
+++ b/src/mol-canvas3d/passes/pick.ts
@@ -32,13 +32,9 @@ export class PickPass {
     private pickHeight: number
 
     constructor(private webgl: WebGLContext, private renderer: Renderer, private scene: Scene, private camera: Camera, private handleHelper: HandleHelper, private pickBaseScale: number, private drawPass: DrawPass) {
-        const { gl } = webgl;
-        const width = gl.drawingBufferWidth;
-        const height = gl.drawingBufferHeight;
-
         this.pickScale = pickBaseScale / webgl.pixelRatio;
-        this.pickWidth = Math.ceil(width * this.pickScale);
-        this.pickHeight = Math.ceil(height * this.pickScale);
+        this.pickWidth = Math.ceil(camera.viewport.width * this.pickScale);
+        this.pickHeight = Math.ceil(camera.viewport.height * this.pickScale);
 
         this.objectPickTarget = webgl.createRenderTarget(this.pickWidth, this.pickHeight);
         this.instancePickTarget = webgl.createRenderTarget(this.pickWidth, this.pickHeight);
@@ -65,12 +61,12 @@ export class PickPass {
             this.pickWidth = pickWidth;
             this.pickHeight = pickHeight;
 
-        this.objectPickTarget.setSize(this.pickWidth, this.pickHeight);
-        this.instancePickTarget.setSize(this.pickWidth, this.pickHeight);
-        this.groupPickTarget.setSize(this.pickWidth, this.pickHeight);
+            this.objectPickTarget.setSize(this.pickWidth, this.pickHeight);
+            this.instancePickTarget.setSize(this.pickWidth, this.pickHeight);
+            this.groupPickTarget.setSize(this.pickWidth, this.pickHeight);
 
-        this.setupBuffers();
-    }
+            this.setupBuffers();
+        }
     }
 
     render() {
@@ -115,17 +111,27 @@ export class PickPass {
     }
 
     identify(x: number, y: number): PickingId | undefined {
-        const { webgl, pickScale } = this;
+        const { webgl, pickScale, camera: { viewport } } = this;
         if (webgl.isContextLost) return;
 
-        const { gl } = webgl;
+        const { gl, pixelRatio } = webgl;
+        x *= pixelRatio;
+        y *= pixelRatio;
+
+        // check if within viewport
+        if (x < viewport.x ||
+            gl.drawingBufferHeight - y < viewport.y ||
+            x > viewport.x + viewport.width ||
+            gl.drawingBufferHeight - y > viewport.y + viewport.height
+        ) return;
+
         if (this.pickDirty) {
             this.render();
             this.syncBuffers();
         }
 
-        x *= webgl.pixelRatio;
-        y *= webgl.pixelRatio;
+        x -= viewport.x * pixelRatio;
+        y += viewport.y * pixelRatio; // plus because of flipped y
         y = gl.drawingBufferHeight - y; // flip y
 
         const xp = Math.floor(x * pickScale);
diff --git a/src/mol-canvas3d/passes/postprocessing.ts b/src/mol-canvas3d/passes/postprocessing.ts
index d5a9914ba17602d33ae3dcbd0b01505bc55d30fb..a14fca29d853856157b05c26e71e08bbe7e55aff 100644
--- a/src/mol-canvas3d/passes/postprocessing.ts
+++ b/src/mol-canvas3d/passes/postprocessing.ts
@@ -103,8 +103,7 @@ export class PostprocessingPass {
     renderable: PostprocessingRenderable
 
     constructor(private webgl: WebGLContext, private camera: Camera, drawPass: DrawPass, props: Partial<PostprocessingProps>) {
-        const { gl } = webgl;
-        this.target = webgl.createRenderTarget(gl.drawingBufferWidth, gl.drawingBufferHeight, false);
+        this.target = webgl.createRenderTarget(camera.viewport.width, camera.viewport.height, false);
         this.props = { ...PD.getDefaultValues(PostprocessingParams), ...props };
         const { colorTarget, depthTexture, packedDepth } = drawPass;
         this.renderable = getPostprocessingRenderable(webgl, colorTarget.texture, depthTexture, packedDepth, this.props);
@@ -117,9 +116,9 @@ export class PostprocessingPass {
     setSize(width: number, height: number) {
         const [w, h] = this.renderable.values.uTexSize.ref.value;
         if (width !== w || height !== h) {
-        this.target.setSize(width, height);
-        ValueCell.update(this.renderable.values.uTexSize, Vec2.set(this.renderable.values.uTexSize.ref.value, width, height));
-    }
+            this.target.setSize(width, height);
+            ValueCell.update(this.renderable.values.uTexSize, Vec2.set(this.renderable.values.uTexSize.ref.value, width, height));
+        }
     }
 
     setProps(props: Partial<PostprocessingProps>) {
@@ -149,6 +148,12 @@ export class PostprocessingPass {
         this.renderable.update();
     }
 
+    private setQuadShift(x: number, y: number) {
+        ValueCell.update(this.renderable.values.uQuadShift, Vec2.set(
+            this.renderable.values.uQuadShift.ref.value, x, y)
+        );
+    }
+
     render(toDrawingBuffer: boolean) {
         ValueCell.updateIfChanged(this.renderable.values.uFar, this.camera.far);
         ValueCell.updateIfChanged(this.renderable.values.uNear, this.camera.near);
@@ -161,12 +166,16 @@ export class PostprocessingPass {
             this.renderable.update();
         }
 
+        const { x, y, width, height } = this.camera.viewport;
         const { gl, state } = this.webgl;
         if (toDrawingBuffer) {
             this.webgl.unbindFramebuffer();
-            gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
+            this.setQuadShift(x / width, y / height);
+            gl.viewport(x, y, width, height);
         } else {
             this.target.bind();
+            this.setQuadShift(0, 0);
+            gl.viewport(0, 0, width, height);
         }
         state.disable(gl.SCISSOR_TEST);
         state.disable(gl.BLEND);
diff --git a/src/mol-canvas3d/util.ts b/src/mol-canvas3d/util.ts
index 01d12b00028ee3037cc059e86485efe378aae216..94dc9221526b37a6b32ebe9885f9a7719bb4a956 100644
--- a/src/mol-canvas3d/util.ts
+++ b/src/mol-canvas3d/util.ts
@@ -5,14 +5,14 @@
  */
 
 /** Set canvas size taking `devicePixelRatio` into account */
-export function setCanvasSize(canvas: HTMLCanvasElement, width: number, height: number) {
-    canvas.width = Math.round(window.devicePixelRatio * width);
-    canvas.height = Math.round(window.devicePixelRatio * height);
+export function setCanvasSize(canvas: HTMLCanvasElement, width: number, height: number, scale = 1) {
+    canvas.width = Math.round(window.devicePixelRatio * scale * width);
+    canvas.height = Math.round(window.devicePixelRatio * scale * height);
     Object.assign(canvas.style, { width: `${width}px`, height: `${height}px` });
 }
 
 /** Resize canvas to container element taking `devicePixelRatio` into account */
-export function resizeCanvas (canvas: HTMLCanvasElement, container: Element) {
+export function resizeCanvas (canvas: HTMLCanvasElement, container: Element, scale = 1) {
     let width = window.innerWidth;
     let height = window.innerHeight;
     if (container !== document.body) {
@@ -20,7 +20,7 @@ export function resizeCanvas (canvas: HTMLCanvasElement, container: Element) {
         width = bounds.right - bounds.left;
         height = bounds.bottom - bounds.top;
     }
-    setCanvasSize(canvas, width, height);
+    setCanvasSize(canvas, width, height, scale);
 }
 
 function _canvasToBlob(canvas: HTMLCanvasElement, callback: BlobCallback, type?: string, quality?: any) {
diff --git a/src/mol-gl/compute/util.ts b/src/mol-gl/compute/util.ts
index 645d4b90146570e047a2a524ef4ea6039ee85132..4d2cfee814dacd1f5859d6d02116914579fa4a6a 100644
--- a/src/mol-gl/compute/util.ts
+++ b/src/mol-gl/compute/util.ts
@@ -22,6 +22,7 @@ export const QuadSchema = {
     instanceCount: ValueSpec('number'),
     aPosition: AttributeSpec('float32', 2, 0),
     uQuadScale: UniformSpec('v2'),
+    uQuadShift: UniformSpec('v2'),
 };
 
 export const QuadValues: Values<typeof QuadSchema> = {
@@ -29,6 +30,7 @@ export const QuadValues: Values<typeof QuadSchema> = {
     instanceCount: ValueCell.create(1),
     aPosition: ValueCell.create(QuadPositions),
     uQuadScale: ValueCell.create(Vec2.create(1, 1)),
+    uQuadShift: ValueCell.create(Vec2.create(0, 0)),
 };
 
 //
diff --git a/src/mol-gl/renderer.ts b/src/mol-gl/renderer.ts
index 8a5b386d582869c104389956bea4e4bd3f568f79..8c4ba27b91810f74e7fab5daf0b6e21f62d63f18 100644
--- a/src/mol-gl/renderer.ts
+++ b/src/mol-gl/renderer.ts
@@ -329,7 +329,7 @@ namespace Renderer {
 
             const { renderables } = group;
 
-            state.disable(gl.SCISSOR_TEST);
+            state.enable(gl.SCISSOR_TEST);
             state.disable(gl.BLEND);
             state.colorMask(true, true, true, true);
             state.enable(gl.DEPTH_TEST);
@@ -377,6 +377,7 @@ namespace Renderer {
 
         return {
             clear: (transparentBackground: boolean) => {
+                state.enable(gl.SCISSOR_TEST);
                 state.depthMask(true);
                 state.colorMask(true, true, true, true);
                 state.clearColor(bgColor[0], bgColor[1], bgColor[2], transparentBackground ? 0 : 1);
@@ -442,6 +443,7 @@ namespace Renderer {
             },
             setViewport: (x: number, y: number, width: number, height: number) => {
                 gl.viewport(x, y, width, height);
+                gl.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.uViewportHeight, height);
diff --git a/src/mol-gl/shader/compose.frag.ts b/src/mol-gl/shader/compose.frag.ts
index 172330fded2b7030fa5bd855b6e15a36c4aeeb5e..9c2abd85da9a607c4bebbaa85323c0aab9f19744 100644
--- a/src/mol-gl/shader/compose.frag.ts
+++ b/src/mol-gl/shader/compose.frag.ts
@@ -2,12 +2,14 @@ export default `
 precision highp float;
 precision highp sampler2D;
 
+uniform vec2 uQuadShift;
+
 uniform sampler2D tColor;
 uniform vec2 uTexSize;
 uniform float uWeight;
 
 void main() {
-    vec2 coords = gl_FragCoord.xy / uTexSize;
+    vec2 coords = gl_FragCoord.xy / uTexSize - uQuadShift;
     gl_FragColor = texture2D(tColor, coords) * uWeight;
 }
 `;
\ No newline at end of file
diff --git a/src/mol-gl/shader/postprocessing.frag.ts b/src/mol-gl/shader/postprocessing.frag.ts
index 04d2864cfa23fb87280c970c2ca764a13db04064..d1558bc0969c7ff2f188e9c15d9c9a1741f3d0f5 100644
--- a/src/mol-gl/shader/postprocessing.frag.ts
+++ b/src/mol-gl/shader/postprocessing.frag.ts
@@ -3,6 +3,8 @@ precision highp float;
 precision highp int;
 precision highp sampler2D;
 
+uniform vec2 uQuadShift;
+
 uniform sampler2D tColor;
 uniform sampler2D tPackedDepth;
 uniform vec2 uTexSize;
@@ -94,7 +96,7 @@ vec2 calcEdgeDepth(const in vec2 coords) {
 }
 
 void main(void) {
-    vec2 coords = gl_FragCoord.xy / uTexSize;
+    vec2 coords = gl_FragCoord.xy / uTexSize - uQuadShift;
     vec4 color = texture2D(tColor, coords);
 
     #ifdef dOutlineEnable
diff --git a/src/mol-gl/webgl/context.ts b/src/mol-gl/webgl/context.ts
index e0b983e70ee88c0194aa81018a759f603ffbfe5e..20bb667561c25cba388b00a7cdc190c559f78ced 100644
--- a/src/mol-gl/webgl/context.ts
+++ b/src/mol-gl/webgl/context.ts
@@ -28,10 +28,6 @@ export function getGLContext(canvas: HTMLCanvasElement, contextAttributes?: WebG
     return getContext('webgl2') ||  getContext('webgl') || getContext('experimental-webgl');
 }
 
-function getPixelRatio() {
-    return (typeof window !== 'undefined') ? window.devicePixelRatio : 1;
-}
-
 export function getErrorDescription(gl: GLRenderingContext, error: number) {
     switch (error) {
         case gl.NO_ERROR: return 'no error';
@@ -206,7 +202,7 @@ export interface WebGLContext {
     destroy: () => void
 }
 
-export function createContext(gl: GLRenderingContext): WebGLContext {
+export function createContext(gl: GLRenderingContext, props: Partial<{ pixelScale: number }> = {}): WebGLContext {
     const extensions = createExtensions(gl);
     const state = createState(gl);
     const stats = createStats();
@@ -269,8 +265,8 @@ export function createContext(gl: GLRenderingContext): WebGLContext {
         gl,
         isWebGL2: isWebGL2(gl),
         get pixelRatio () {
-            // this can change during the lifetime of a rendering context, so need to re-obtain on access
-            return getPixelRatio();
+            const dpr = (typeof window !== 'undefined') ? window.devicePixelRatio : 1;
+            return dpr * (props.pixelScale || 1);
         },
 
         extensions,
diff --git a/src/mol-plugin-ui/viewport/canvas.tsx b/src/mol-plugin-ui/viewport/canvas.tsx
index bbbc988df9e60c2a60bda72f7d32022a5d33262b..cbe501ae3d5184de2d93f4fbf97f25d38e4b7d61 100644
--- a/src/mol-plugin-ui/viewport/canvas.tsx
+++ b/src/mol-plugin-ui/viewport/canvas.tsx
@@ -10,6 +10,7 @@ import { PluginUIComponent } from '../base';
 import { resizeCanvas } from '../../mol-canvas3d/util';
 import { Subject } from 'rxjs';
 import { debounceTime } from 'rxjs/internal/operators/debounceTime';
+import { PluginConfig } from '../../mol-plugin/config';
 
 interface ViewportCanvasState {
     noWebGl: boolean
@@ -23,7 +24,7 @@ export interface ViewportCanvasParams {
     parentClassName?: string,
     parentStyle?: React.CSSProperties,
     hostClassName?: string,
-    hostStyle?: React.CSSProperties
+    hostStyle?: React.CSSProperties,
 }
 
 export class ViewportCanvas extends PluginUIComponent<ViewportCanvasParams, ViewportCanvasState> {
@@ -43,7 +44,8 @@ export class ViewportCanvas extends PluginUIComponent<ViewportCanvasParams, View
         const container = this.container.current;
         const canvas = this.canvas.current;
         if (container && canvas) {
-            resizeCanvas(canvas, container);
+            const pixelScale = this.plugin.config.get(PluginConfig.General.PixelScale) || 1;
+            resizeCanvas(canvas, container, pixelScale);
             this.plugin.canvas3d!.handleResize();
         }
     }
diff --git a/src/mol-plugin/config.ts b/src/mol-plugin/config.ts
index c6f857485d491da0cef514b6aac4abe32f425259..3e697f85b8304e81c0ce546e54b54cff75e04e7b 100644
--- a/src/mol-plugin/config.ts
+++ b/src/mol-plugin/config.ts
@@ -23,7 +23,8 @@ export const PluginConfig = {
     item,
     General: {
         IsBusyTimeoutMs: item('plugin-config.is-busy-timeout', 750),
-        DisableAntialiasing: item('plugin-config.disable-antialiasing', false)
+        DisableAntialiasing: item('plugin-config.disable-antialiasing', false),
+        PixelScale: item('plugin-config.pixel-scale', 1)
     },
     State: {
         DefaultServer: item('plugin-state.server', 'https://webchem.ncbr.muni.cz/molstar-state'),
diff --git a/src/mol-plugin/context.ts b/src/mol-plugin/context.ts
index c2c28b74bc8e66ccde06f2dcfdf556981fbe4b05..61e2a6833aad2282810ecef47be9bfb56e58a71f 100644
--- a/src/mol-plugin/context.ts
+++ b/src/mol-plugin/context.ts
@@ -187,7 +187,8 @@ export class PluginContext {
             if (this.spec.layout && this.spec.layout.initial) this.layout.setProps(this.spec.layout.initial);
 
             const antialias = !(this.config.get(PluginConfig.General.DisableAntialiasing) ?? false);
-            (this.canvas3d as Canvas3D) = Canvas3D.fromCanvas(canvas, {}, { antialias });
+            const pixelScale = this.config.get(PluginConfig.General.PixelScale) || 1;
+            (this.canvas3d as Canvas3D) = Canvas3D.fromCanvas(canvas, {}, { antialias, pixelScale });
             this.canvas3dInit.next(true);
             let props = this.spec.components?.viewport?.canvas3d;
 
diff --git a/src/mol-util/input/input-observer.ts b/src/mol-util/input/input-observer.ts
index e93b8404c8e8aa4583eaf62ef1c349b2ae213fd6..c81e787ebb7683944876ea59dc4ef853c1b0185d 100644
--- a/src/mol-util/input/input-observer.ts
+++ b/src/mol-util/input/input-observer.ts
@@ -57,7 +57,11 @@ export const DefaultInputObserverProps = {
     noScroll: true,
     noMiddleClickScroll: true,
     noContextMenu: true,
-    noPinchZoom: true
+    noPinchZoom: true,
+    noTextSelect: true,
+    mask: (x: number, y: number) => true,
+
+    pixelScale: 1
 };
 export type InputObserverProps = Partial<typeof DefaultInputObserverProps>
 
@@ -133,6 +137,8 @@ export type DragInput = {
 } & BaseInput
 
 export type WheelInput = {
+    x: number,
+    y: number,
     dx: number,
     dy: number,
     dz: number,
@@ -180,12 +186,18 @@ type PointerEvent = {
     clientY: number
     pageX: number
     pageY: number
+
+    preventDefault?: () => void
 }
 
 interface InputObserver {
     noScroll: boolean
     noContextMenu: boolean
 
+    readonly width: number
+    readonly height: number
+    readonly pixelRatio: number
+
     readonly drag: Observable<DragInput>,
     // Equivalent to mouseUp and touchEnd
     readonly interactionEnd: Observable<undefined>,
@@ -227,6 +239,10 @@ namespace InputObserver {
             noScroll,
             noContextMenu,
 
+            width: 0,
+            height: 0,
+            pixelRatio: 1,
+
             ...createEvents(),
 
             dispose: noop
@@ -234,7 +250,10 @@ namespace InputObserver {
     }
 
     export function fromElement(element: Element, props: InputObserverProps = {}): InputObserver {
-        let { noScroll, noMiddleClickScroll, noContextMenu, noPinchZoom } = { ...DefaultInputObserverProps, ...props };
+        let { noScroll, noMiddleClickScroll, noContextMenu, noPinchZoom, noTextSelect, mask, pixelScale } = { ...DefaultInputObserverProps, ...props };
+
+        let width = element.clientWidth * pixelRatio();
+        let height = element.clientHeight * pixelRatio();
 
         let lastTouchDistance = 0;
         const pointerDown = Vec2();
@@ -249,6 +268,10 @@ namespace InputObserver {
             meta: false
         };
 
+        function pixelRatio() {
+            return window.devicePixelRatio * pixelScale;
+        }
+
         function getModifierKeys(): ModifiersKeys {
             return { ...modifierKeys };
         }
@@ -270,13 +293,17 @@ namespace InputObserver {
             get noContextMenu () { return noContextMenu; },
             set noContextMenu (value: boolean) { noContextMenu = value; },
 
+            get width () { return width; },
+            get height () { return height; },
+            get pixelRatio () { return pixelRatio(); },
+
             ...events,
 
             dispose
         };
 
         function attach() {
-            element.addEventListener('contextmenu', onContextMenu, false );
+            element.addEventListener('contextmenu', onContextMenu as any, false );
 
             element.addEventListener('wheel', onMouseWheel as any, false);
             element.addEventListener('mousedown', onMouseDown as any, false);
@@ -306,7 +333,7 @@ namespace InputObserver {
             if (disposed) return;
             disposed = true;
 
-            element.removeEventListener( 'contextmenu', onContextMenu, false );
+            element.removeEventListener( 'contextmenu', onContextMenu as any, false );
 
             element.removeEventListener('wheel', onMouseWheel as any, false);
             element.removeEventListener('mousedown', onMouseDown as any, false);
@@ -328,7 +355,9 @@ namespace InputObserver {
             window.removeEventListener('resize', onResize, false);
         }
 
-        function onContextMenu(event: Event) {
+        function onContextMenu(event: MouseEvent) {
+            if (!mask(event.clientX, event.clientY)) return;
+
             if (noContextMenu) {
                 event.preventDefault();
             }
@@ -498,6 +527,7 @@ namespace InputObserver {
         function onPointerDown(ev: PointerEvent) {
             eventOffset(pointerStart, ev);
             Vec2.copy(pointerDown, pointerStart);
+            if (!mask(ev.clientX, ev.clientY)) return;
 
             if (insideBounds(pointerStart)) {
                 dragging = DraggingState.Started;
@@ -525,10 +555,16 @@ namespace InputObserver {
 
             if (dragging === DraggingState.Stopped) return;
 
+            if (noTextSelect) {
+                ev.preventDefault?.();
+            }
+
             Vec2.div(pointerDelta, Vec2.sub(pointerDelta, pointerEnd, pointerStart), getClientSize(rectSize));
             if (Vec2.magnitude(pointerDelta) < EPSILON) return;
 
             const isStart = dragging === DraggingState.Started;
+            if (isStart && !mask(ev.clientX, ev.clientY)) return;
+
             const [ dx, dy ] = pointerDelta;
             drag.next({ x, y, dx, dy, pageX, pageY, buttons, button, modifiers: getModifierKeys(), isStart });
 
@@ -537,6 +573,10 @@ namespace InputObserver {
         }
 
         function onMouseWheel(ev: WheelEvent) {
+            eventOffset(pointerEnd, ev);
+            const [ x, y ] = pointerEnd;
+            if (!mask(ev.clientX, ev.clientY)) return;
+
             if (noScroll) {
                 ev.preventDefault();
             }
@@ -555,7 +595,7 @@ namespace InputObserver {
             buttons = button = ButtonsType.Flag.Auxilary;
 
             if (dx || dy || dz) {
-                wheel.next({ dx, dy, dz, buttons, button, modifiers: getModifierKeys() });
+                wheel.next({ x, y, dx, dy, dz, buttons, button, modifiers: getModifierKeys() });
             }
         }
 
@@ -589,6 +629,9 @@ namespace InputObserver {
         }
 
         function eventOffset(out: Vec2, ev: PointerEvent) {
+            width = element.clientWidth * pixelRatio();
+            height = element.clientHeight * pixelRatio();
+
             const cx = ev.clientX || 0;
             const cy = ev.clientY || 0;
             const rect = element.getBoundingClientRect();