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