From 725c9a7008610fd11f0e6dd0443533b598f79d1d Mon Sep 17 00:00:00 2001 From: Fritz Lekschas Date: Sat, 28 Dec 2024 12:05:56 +0100 Subject: [PATCH] feat: improve rendering and export functionality Address #175 by adding `antiAliasing` and `pixelAligned` properties. This PR also exposes the renderer's `resize()` function. --- CHANGELOG.md | 7 +++ README.md | 9 ++- src/constants.js | 2 + src/index.js | 127 ++++++++++++++++++++++++++++++++++++++++-- src/point.fs | 4 +- src/point.vs | 15 ++++- src/renderer.js | 28 +++++++--- src/types.d.ts | 7 +++ tests/methods.test.js | 32 +++++++++++ 9 files changed, 216 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a52d379..f820938 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## 1.12.0 + +- Feat: add support for adjusting the anti-aliasing via `scatterplot.set({ antiAliasing: 1 })`. ([#175](https://github.com/flekschas/regl-scatterplot/issues/175)) +- Feat: add support for aligning points with the pixel grid via `scatterplot.set({ pixelAligned: true })`. ([#175](https://github.com/flekschas/regl-scatterplot/issues/175)) +- Feat: enhance `scatterplot.export()` by allowing to adjust the scale, anti-aliasing, and pixel alignment. Note that when customizing the render setting for export, the function returns a promise that resolves into `ImageData`. +- Feat: expose `resize()` method of the `renderer`. + ## 1.11.4 - Fix: allow setting the lasso long press indicator parent element diff --git a/README.md b/README.md index 9d19ee3..73630c4 100644 --- a/README.md +++ b/README.md @@ -707,9 +707,12 @@ Sets the view back to the initially defined view. This will trigger a `view` eve **Arguments:** -- `options` is an object for customizing how to export. See [regl.read()](https://github.com/regl-project/regl/blob/master/API.md#reading-pixels) for details. +- `options` is an object for customizing the render settings during the export: + - `scale`: is a float number allowning to adjust the exported image size + - `antiAliasing`: is a float allowing to adjust the anti-aliasing factor + - `pixelAligned`: is a Boolean allowing to adjust the point alignment with the pixel grid -**Returns:** an object with three properties: `pixels`, `width`, and `height`. The `pixels` is a `Uint8ClampedArray`. +**Returns:** an [`ImageData`](https://developer.mozilla.org/en-US/docs/Web/API/ImageData) object if `option` is `undefined`. Otherwise it returns a Promise resolving to an [`ImageData`](https://developer.mozilla.org/en-US/docs/Web/API/ImageData) object. # scatterplot.subscribe(eventName, eventHandler) @@ -818,6 +821,8 @@ can be read and written via [`scatterplot.get()`](#scatterplot.get) and [`scatte | annotationLineColor | string or quadruple | `[1, 1, 1, 0.1]` | hex, rgb, rgba | `true` | `false` | | annotationLineWidth | number | `1` | | `true` | `false` | | annotationHVLineLimit | number | `1000` | the extent of horizontal or vertical lines | `true` | `false` | +| antiAliasing | number | `0.5` | higher values result in more blurry points | `true` | `false` | +| pixelAligned | number | `false` | if true, points are aligned with the pixel grid | `true` | `false` | # Notes: diff --git a/src/constants.js b/src/constants.js index 5f004f5..fe3e1fc 100644 --- a/src/constants.js +++ b/src/constants.js @@ -167,6 +167,8 @@ export const W_NAMES = new Set(['w', 'valueW', 'valueB', 'value2', 'value']); export const DEFAULT_IMAGE_LOAD_TIMEOUT = 15000; export const DEFAULT_SPATIAL_INDEX_USE_WORKER = undefined; export const DEFAULT_CAMERA_IS_FIXED = false; +export const DEFAULT_ANTI_ALIASING = 0.5; +export const DEFAULT_PIXEL_ALIGNED = false; // Error messages export const ERROR_POINTS_NOT_DRAWN = 'Points have not been drawn'; diff --git a/src/index.js b/src/index.js index 6788cff..228e30d 100644 --- a/src/index.js +++ b/src/index.js @@ -37,6 +37,7 @@ import { DEFAULT_ANNOTATION_HVLINE_LIMIT, DEFAULT_ANNOTATION_LINE_COLOR, DEFAULT_ANNOTATION_LINE_WIDTH, + DEFAULT_ANTI_ALIASING, DEFAULT_BACKGROUND_IMAGE, DEFAULT_CAMERA_IS_FIXED, DEFAULT_COLOR_ACTIVE, @@ -70,6 +71,7 @@ import { DEFAULT_OPACITY_INACTIVE_MAX, DEFAULT_OPACITY_INACTIVE_SCALE, DEFAULT_PERFORMANCE_MODE, + DEFAULT_PIXEL_ALIGNED, DEFAULT_POINT_CONNECTION_COLOR_ACTIVE, DEFAULT_POINT_CONNECTION_COLOR_BY, DEFAULT_POINT_CONNECTION_COLOR_HOVER, @@ -234,6 +236,8 @@ const createScatterplot = ( let { renderer, + antiAliasing = DEFAULT_ANTI_ALIASING, + pixelAligned = DEFAULT_PIXEL_ALIGNED, backgroundColor = DEFAULT_COLOR_BG, backgroundImage = DEFAULT_BACKGROUND_IMAGE, canvas = document.createElement('canvas'), @@ -1464,6 +1468,7 @@ const createScatterplot = ( ); }; + const getAntiAliasing = () => antiAliasing; const getResolution = () => [canvas.width, canvas.height]; const getBackgroundImage = () => backgroundImage; const getColorTex = () => colorTex; @@ -1519,6 +1524,7 @@ const createScatterplot = ( const getIsOpacityByDensity = () => +(opacityBy === 'density'); const getIsSizedByZ = () => +(sizeBy === 'valueZ'); const getIsSizedByW = () => +(sizeBy === 'valueW'); + const getIsPixelAligned = () => +pixelAligned; const getColorMultiplicator = () => { if (colorBy === 'valueZ') { return valueZDataType === CONTINUOUS ? pointColor.length - 1 : 1; @@ -1632,6 +1638,7 @@ const createScatterplot = ( }, uniforms: { + antiAliasing: getAntiAliasing, resolution: getResolution, modelViewProjection: getModelViewProjection, devicePixelRatio: getDevicePixelRatio, @@ -1656,11 +1663,14 @@ const createScatterplot = ( isOpacityByDensity: getIsOpacityByDensity, isSizedByZ: getIsSizedByZ, isSizedByW: getIsSizedByW, + isPixelAligned: getIsPixelAligned, colorMultiplicator: getColorMultiplicator, opacityMultiplicator: getOpacityMultiplicator, opacityDensity: getOpacityDensity, sizeMultiplicator: getSizeMultiplicator, numColorStates: COLOR_NUM_STATES, + drawingBufferWidth: (context) => context.drawingBufferWidth, + drawingBufferHeight: (context) => context.drawingBufferHeight, }, count: getNumPoints, @@ -3273,6 +3283,14 @@ const createScatterplot = ( renderer.gamma = newGamma; }; + const setAntiAliasing = (newAntiAliasing) => { + antiAliasing = Number(newAntiAliasing) || 0.5; + }; + + const setPixelAligned = (newPixelAligned) => { + pixelAligned = Boolean(newPixelAligned); + }; + /** @type {(property: Key) => import('./types').Properties[Key] } */ const get = (property) => { checkDeprecations({ property: true }); @@ -3608,10 +3626,18 @@ const createScatterplot = ( return annotationHVLineLimit; } + if (property === 'antiAliasing') { + return antiAliasing; + } + + if (property === 'pixelAligned') { + return pixelAligned; + } + return undefined; }; - /** @type {(properties: Partial) => void} */ + /** @type {(properties: Partial) => Promise} */ const set = (properties = {}) => { checkDeprecations(properties); @@ -3878,6 +3904,14 @@ const createScatterplot = ( setAnnotationHVLineLimit(properties.annotationHVLineLimit); } + if (properties.antiAliasing !== undefined) { + setAntiAliasing(properties.antiAliasing); + } + + if (properties.pixelAligned !== undefined) { + setPixelAligned(properties.pixelAligned); + } + // setWidth and setHeight can be async when width or height are set to // 'auto'. And since draw() would have anyway been async we can just make // all calls async. @@ -4027,9 +4061,94 @@ const createScatterplot = ( } }; - /** @type {() => ImageData} */ - const exportFn = () => - canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height); + /** + * Export view as `ImageData` using custom render settings + * @param {import('./types').ScatterplotMethodOptions['export']} options + * @returns {Promise} + */ + const exportFnAdvanced = async (options) => { + canvas.style.userSelect = 'none'; + + const dpr = window.devicePixelRatio; + + const currPointSize = pointSize; + const currWidth = width; + const currHeight = height; + const currRendererWidth = renderer.canvas.width / dpr; + const currRendererHeight = renderer.canvas.height / dpr; + const currPixelAligned = pixelAligned; + const currAntiAliasing = antiAliasing; + + const scale = options?.scale || 1; + const newPointSize = Array.isArray(pointSize) + ? pointSize.map((s) => s * scale) + : pointSize * scale; + const newWidth = currentWidth * scale; + const newHeight = currentHeight * scale; + + setPointSize(newPointSize); + setWidth(newWidth); + setHeight(newHeight); + setPixelAligned(options?.pixelAligned || pixelAligned); + setAntiAliasing(options?.antiAliasing || antiAliasing); + + renderer.resize(width, height); + renderer.refresh(); + + await new Promise((resolve) => { + pubSub.subscribe('draw', resolve); + redraw(); + }); + + const imageData = canvas + .getContext('2d') + .getImageData(0, 0, canvas.width, canvas.height); + + renderer.resize(currRendererWidth, currRendererHeight); + renderer.refresh(); + + setPointSize(currPointSize); + setWidth(currWidth); + setHeight(currHeight); + setPixelAligned(currPixelAligned); + setAntiAliasing(currAntiAliasing); + + await new Promise((resolve) => { + pubSub.subscribe('draw', resolve); + redraw(); + }); + + canvas.style.userSelect = null; + + return imageData; + }; + + /** + * Export view as `ImageData` using the current render settings + * @overload + * @param {undefined} options + * @return {ImageData} + */ + /** + * Export view as `ImageData` using custom render settings + * @overload + * @param {import('./types').ScatterplotMethodOptions['export']} options + * @return {Promise} + */ + /** + * Export view + * @param {import('./types').ScatterplotMethodOptions['export']} [options] + * @returns {Promise} + */ + const exportFn = (options) => { + if (options === undefined) { + return canvas + .getContext('2d') + .getImageData(0, 0, canvas.width, canvas.height); + } + + return exportFnAdvanced(options); + }; const init = () => { updateViewAspectRatio(); diff --git a/src/point.fs b/src/point.fs index 7870988..4b6cfec 100644 --- a/src/point.fs +++ b/src/point.fs @@ -1,6 +1,8 @@ const FRAGMENT_SHADER = ` precision highp float; +uniform float antiAliasing; + varying vec4 color; varying float finalPointSize; @@ -11,7 +13,7 @@ float linearstep(float edge0, float edge1, float x) { void main() { vec2 c = gl_PointCoord * 2.0 - 1.0; float sdf = length(c) * finalPointSize; - float alpha = linearstep(finalPointSize + 0.5, finalPointSize - 0.5, sdf); + float alpha = linearstep(finalPointSize + antiAliasing, finalPointSize - antiAliasing, sdf); gl_FragColor = vec4(color.rgb, alpha * color.a); } diff --git a/src/point.vs b/src/point.vs index 3f07999..c4cb35c 100644 --- a/src/point.vs +++ b/src/point.vs @@ -23,12 +23,15 @@ uniform float isOpacityByW; uniform float isOpacityByDensity; uniform float isSizedByZ; uniform float isSizedByW; +uniform float isPixelAligned; uniform float colorMultiplicator; uniform float opacityMultiplicator; uniform float opacityDensity; uniform float sizeMultiplicator; uniform float numColorStates; uniform float pointScale; +uniform float drawingBufferWidth; +uniform float drawingBufferHeight; uniform mat4 modelViewProjection; attribute vec2 stateIndex; @@ -39,7 +42,17 @@ varying float finalPointSize; void main() { vec4 state = texture2D(stateTex, stateIndex); - gl_Position = modelViewProjection * vec4(state.x, state.y, 0.0, 1.0); + if (isPixelAligned < 0.5) { + gl_Position = modelViewProjection * vec4(state.x, state.y, 0.0, 1.0); + } else { + vec4 clipSpacePosition = modelViewProjection * vec4(state.x, state.y, 0.0, 1.0); + vec2 ndcPosition = clipSpacePosition.xy / clipSpacePosition.w; + vec2 pixelPos = 0.5 * (ndcPosition + 1.0) * vec2(drawingBufferWidth, drawingBufferHeight); + pixelPos = floor(pixelPos + 0.5); // Snap to nearest pixel + vec2 snappedPosition = (pixelPos / vec2(drawingBufferWidth, drawingBufferHeight)) * 2.0 - 1.0; + gl_Position = vec4(snappedPosition, 0.0, 1.0); + } + // Determine color index float colorIndexZ = isColoredByZ * floor(state.z * colorMultiplicator); diff --git a/src/renderer.js b/src/renderer.js index 09025f1..fdc1186 100644 --- a/src/renderer.js +++ b/src/renderer.js @@ -144,7 +144,10 @@ export const createRenderer = ( } }); - const resize = () => { + const resize = ( + /** @type {number} */ customWidth, + /** @type {number} */ customHeight, + ) => { // We need to limit the width and height by the screen size to prevent // a bug in VSCode where the window height is said to be taller than the // screen height. The problem with too large dimensions is that at some @@ -157,8 +160,14 @@ export const createRenderer = ( // @see // https://github.com/microsoft/vscode/issues/225808 // https://github.com/flekschas/jupyter-scatter/issues/37 - const width = Math.min(window.innerWidth, window.screen.availWidth); - const height = Math.min(window.innerHeight, window.screen.availHeight); + const width = + customWidth === undefined + ? Math.min(window.innerWidth, window.screen.availWidth) + : customWidth; + const height = + customHeight === undefined + ? Math.min(window.innerHeight, window.screen.availHeight) + : customHeight; canvas.width = width * window.devicePixelRatio; canvas.height = height * window.devicePixelRatio; fboRes[0] = canvas.width; @@ -166,9 +175,13 @@ export const createRenderer = ( fbo.resize(...fboRes); }; + const resizeHandler = () => { + resize(); + }; + if (!options.canvas) { - window.addEventListener('resize', resize); - window.addEventListener('orientationchange', resize); + window.addEventListener('resize', resizeHandler); + window.addEventListener('orientationchange', resizeHandler); resize(); } @@ -177,8 +190,8 @@ export const createRenderer = ( */ const destroy = () => { isDestroyed = true; - window.removeEventListener('resize', resize); - window.removeEventListener('orientationchange', resize); + window.removeEventListener('resize', resizeHandler); + window.removeEventListener('orientationchange', resizeHandler); frame.cancel(); canvas = undefined; regl.destroy(); @@ -229,6 +242,7 @@ export const createRenderer = ( return isDestroyed; }, render, + resize, onFrame, refresh, destroy, diff --git a/src/types.d.ts b/src/types.d.ts index 415b99d..59b7fbf 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -165,6 +165,8 @@ interface BaseOptions { yScale: null | Scale; pointScaleMode: PointScaleMode; cameraIsFixed: boolean; + antiAliasing: number; + pixelAligned: boolean; } // biome-ignore lint/style/useNamingConvention: KDBush is a library name @@ -265,6 +267,11 @@ export interface ScatterplotMethodOptions { transitionDuration: number; transitionEasing: (t: number) => number; }>; + export: Partial<{ + scale: number; + antiAliasing: number; + pixelAligned: boolean; + }>; } export type Events = import('pub-sub-es').Event< diff --git a/tests/methods.test.js b/tests/methods.test.js index 14fe657..b3e020f 100644 --- a/tests/methods.test.js +++ b/tests/methods.test.js @@ -896,3 +896,35 @@ test('pointScaleMode', async () => { scatterplot.destroy(); }); + +test('export()', async () => { + const dim = 10; + const scatterplot = createScatterplot({ + canvas: createCanvas(dim, dim), + width: dim, + height: dim, + pointSize: 4, + }); + + await scatterplot.draw([[0.01, 0.01]]); + + const initialImage = scatterplot.export(); + const initialPixelSum = getPixelSum(initialImage, 0, dim, 0, dim); + + expect(initialPixelSum).toBeGreaterThan(0); + + // Export at two scale + const upscaledImage = await scatterplot.export({ scale: 2 }); + const upscaledPixelSum = getPixelSum(upscaledImage, 0, dim * 2, 0, dim * 2); + expect(upscaledPixelSum).toBeGreaterThan(initialPixelSum); + + // Align point with pixel grid + const pixelAlignedImage = await scatterplot.export({ pixelAligned: true }); + expect(pixelAlignedImage.data).not.toEqual(initialImage.data); + + // Increase anti aliasing + const antiAliasingImage = await scatterplot.export({ antiAliasing: 2 }); + const antiAliasingPixelSum = getPixelSum(antiAliasingImage, 0, dim, 0, dim); + + expect(antiAliasingPixelSum).toBeLessThan(initialPixelSum); +});