From 135f08d3a10df7de79fb6633632640c18de7299d Mon Sep 17 00:00:00 2001 From: SimonShiki Date: Sun, 18 Jan 2026 16:58:09 +0800 Subject: [PATCH 1/5] :sparkles: feat(render): high-quality line Signed-off-by: SimonShiki --- packages/render/src/PenSkin.js | 89 ++++++++++++++++++++++---- packages/render/src/RenderConstants.js | 10 ++- packages/render/src/RenderWebGL.js | 6 +- 3 files changed, 88 insertions(+), 17 deletions(-) diff --git a/packages/render/src/PenSkin.js b/packages/render/src/PenSkin.js index 9e737652c..87242c1b0 100644 --- a/packages/render/src/PenSkin.js +++ b/packages/render/src/PenSkin.js @@ -101,6 +101,10 @@ class PenSkin extends Skin { this.onNativeSizeChanged = this.onNativeSizeChanged.bind(this); this._renderer.on(RenderConstants.Events.NativeSizeChanged, this.onNativeSizeChanged); + this.onCanvasSizeChanged = this.onCanvasSizeChanged.bind(this); + this._renderer.on(RenderConstants.Events.CanvasSizeChanged, this.onCanvasSizeChanged); + this._canvasSize = [this._renderer.gl.canvas.width, this._renderer.gl.canvas.height]; + this._setCanvasSize(renderer.getNativeSize()); } @@ -109,6 +113,7 @@ class PenSkin extends Skin { */ dispose () { this._renderer.removeListener(RenderConstants.Events.NativeSizeChanged, this.onNativeSizeChanged); + this._renderer.removeListener(RenderConstants.Events.CanvasSizeChanged, this.onCanvasSizeChanged); this._renderer.gl.deleteTexture(this._texture); this._texture = null; super.dispose(); @@ -191,7 +196,7 @@ class PenSkin extends Skin { twgl.bindFramebufferInfo(gl, this._framebuffer); - gl.viewport(0, 0, this._size[0], this._size[1]); + gl.viewport(0, 0, this._canvasSize[0], this._canvasSize[1]); const currentShader = this._lineShader; gl.useProgram(currentShader.program); @@ -199,7 +204,7 @@ class PenSkin extends Skin { const uniforms = { u_skin: this._texture, - u_stageSize: this._size + u_stageSize: this._canvasSize }; twgl.setUniforms(currentShader, uniforms); @@ -257,15 +262,31 @@ class PenSkin extends Skin { // can overflow that, because you're squaring the operands, and they could end up as "infinity". // Even GLSL's `length` function won't save us here: // https://asawicki.info/news_1596_watch_out_for_reduced_precision_normalizelength_in_opengl_es - const lineDiffX = x1 - x0; - const lineDiffY = y1 - y0; + + const scaleX = this._canvasSize[0] / this._size[0]; + const scaleY = this._canvasSize[1] / this._size[1]; + + const diameter = penAttributes.diameter || DefaultPenAttributes.diameter; + const thickness = diameter * Math.min(scaleX, scaleY); + + // Adjust for odd-width lines to align with physical pixel centers + const offset = (Math.round(thickness) % 2 === 0) ? 0 : 0.5; + + // Scale the points and thickness to the physical canvas size + const scaledX0 = (x0 * scaleX) + offset; + const scaledY0 = (y0 * scaleY) + offset; + const scaledX1 = (x1 * scaleX) + offset; + const scaledY1 = (y1 * scaleY) + offset; + + const lineDiffX = scaledX1 - scaledX0; + const lineDiffY = scaledY1 - scaledY0; const lineLength = Math.sqrt((lineDiffX * lineDiffX) + (lineDiffY * lineDiffY)); const uniforms = { u_lineColor: __premultipliedColor, - u_lineThickness: penAttributes.diameter || DefaultPenAttributes.diameter, + u_lineThickness: thickness, u_lineLength: lineLength, - u_penPoints: [x0, -y0, lineDiffX, -lineDiffY] + u_penPoints: [scaledX0, -scaledY0, lineDiffX, -lineDiffY] }; twgl.setUniforms(currentShader, uniforms); @@ -283,6 +304,18 @@ class PenSkin extends Skin { this._setCanvasSize(event.newSize); } + /** + * React to a change in the renderer's canvas size. + * @param {object} event - The change event. + */ + onCanvasSizeChanged (event) { + if (this._canvasSize[0] === event.newSize[0] && this._canvasSize[1] === event.newSize[1]) { + return; + } + this._canvasSize = event.newSize; + this._resetBufferSize(); + } + /** * Set the size of the pen canvas. * @param {Array} canvasSize - the new width and height for the canvas. @@ -295,8 +328,20 @@ class PenSkin extends Skin { this._rotationCenter[0] = width / 2; this._rotationCenter[1] = height / 2; + this._resetBufferSize(); + } + + /** + * Reset the buffer size. + * @private + */ + _resetBufferSize () { + const [width, height] = this._canvasSize; + const gl = this._renderer.gl; + const oldTexture = this._texture; + this._texture = twgl.createTexture( gl, { @@ -314,15 +359,35 @@ class PenSkin extends Skin { attachment: this._texture } ]; - if (this._framebuffer) { - twgl.resizeFramebufferInfo(gl, this._framebuffer, attachments, width, height); - } else { - this._framebuffer = twgl.createFramebufferInfo(gl, attachments, width, height); - } + this._framebuffer = twgl.createFramebufferInfo(gl, attachments, width, height); gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT); + if (oldTexture) { + // Draw old canvas to new framebuffer + this._renderer.enterDrawRegion(this._usePenBufferDrawRegionId); + gl.viewport(0, 0, width, height); + + const currentShader = this._renderer._shaderManager.getShader(ShaderManager.DRAW_MODE.default); + gl.useProgram(currentShader.program); + twgl.setBuffersAndAttributes(gl, currentShader, this._renderer._bufferInfo); + + const uniforms = { + u_skin: oldTexture, + u_projectionMatrix: twgl.m4.ortho(width / 2, width / -2, height / -2, height / 2, -1, 1), + u_modelMatrix: twgl.m4.scaling(twgl.v3.create(width, height, 0), twgl.m4.identity()) + }; + + twgl.setTextureParameters(gl, oldTexture, { + minMag: gl.NEAREST + }); + twgl.setUniforms(currentShader, uniforms); + twgl.drawBufferInfo(gl, this._renderer._bufferInfo, gl.TRIANGLES); + + gl.deleteTexture(oldTexture); + } + this._silhouettePixels = new Uint8Array(Math.floor(width * height * 4)); this._silhouetteImageData = new ImageData(width, height); @@ -340,7 +405,7 @@ class PenSkin extends Skin { const gl = this._renderer.gl; gl.readPixels( 0, 0, - this._size[0], this._size[1], + this._canvasSize[0], this._canvasSize[1], gl.RGBA, gl.UNSIGNED_BYTE, this._silhouettePixels ); diff --git a/packages/render/src/RenderConstants.js b/packages/render/src/RenderConstants.js index 052faa132..95989231b 100644 --- a/packages/render/src/RenderConstants.js +++ b/packages/render/src/RenderConstants.js @@ -23,8 +23,14 @@ module.exports = { */ Events: { /** - * NativeSizeChanged event + * NativeSizeChanged event, which related to stage size change. + * @constant {string} */ - NativeSizeChanged: 'NativeSizeChanged' + NativeSizeChanged: 'NativeSizeChanged', + /** + * CanvasSizeChanged event, which related to actual canvas size change. + * @constant {string} + */ + CanvasSizeChanged: 'CanvasSizeChanged' } }; diff --git a/packages/render/src/RenderWebGL.js b/packages/render/src/RenderWebGL.js index 5816a7e0c..e92725223 100644 --- a/packages/render/src/RenderWebGL.js +++ b/packages/render/src/RenderWebGL.js @@ -196,9 +196,6 @@ class RenderWebGL extends EventEmitter { /** @type {ShaderManager} */ this._shaderManager = new ShaderManager(gl); - /** @type {HTMLCanvasElement} */ - this._tempCanvas = document.createElement('canvas'); - /** @type {any} */ this._regionId = null; @@ -283,6 +280,9 @@ class RenderWebGL extends EventEmitter { if (canvas.width !== newWidth || canvas.height !== newHeight) { canvas.width = newWidth; canvas.height = newHeight; + this.emit(RenderConstants.Events.CanvasSizeChanged, { + newSize: [newWidth, newHeight] + }); // Resizing the canvas causes it to be cleared, so redraw it. this.draw(); } From 2c37a37228424eb3937ca95d7b75f1d460badae0 Mon Sep 17 00:00:00 2001 From: SimonShiki Date: Sun, 18 Jan 2026 17:08:31 +0800 Subject: [PATCH 2/5] :bug: fix(render): stamp Signed-off-by: SimonShiki --- packages/render/src/RenderWebGL.js | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/render/src/RenderWebGL.js b/packages/render/src/RenderWebGL.js index e92725223..68e5095e3 100644 --- a/packages/render/src/RenderWebGL.js +++ b/packages/render/src/RenderWebGL.js @@ -1742,17 +1742,25 @@ class RenderWebGL extends EventEmitter { const gl = this._gl; twgl.bindFramebufferInfo(gl, skin._framebuffer); + // Adjust the viewport to the scaled dimensions of the framebuffer. + const scaleX = gl.canvas.width / this._nativeSize[0]; + const scaleY = gl.canvas.height / this._nativeSize[1]; + // Limit size of viewport to the bounds around the stamp Drawable and create the projection matrix for the draw. gl.viewport( - (this._nativeSize[0] * 0.5) + bounds.left, - (this._nativeSize[1] * 0.5) - bounds.top, - bounds.width, - bounds.height + ((this._nativeSize[0] * 0.5) + bounds.left) * scaleX, + ((this._nativeSize[1] * 0.5) - bounds.top) * scaleY, + bounds.width * scaleX, + bounds.height * scaleY ); const projection = twgl.m4.ortho(bounds.left, bounds.right, bounds.top, bounds.bottom, -1, 1); // Draw the stamped sprite onto the PenSkin's framebuffer. - this._drawThese([stampID], ShaderManager.DRAW_MODE.default, projection, {ignoreVisibility: true}); + this._drawThese([stampID], ShaderManager.DRAW_MODE.default, projection, { + ignoreVisibility: true, + framebufferWidth: gl.canvas.width, + framebufferHeight: gl.canvas.height + }); skin._silhouetteDirty = true; } From 96cbaf30125208af7562e23735021e8988959628 Mon Sep 17 00:00:00 2001 From: SimonShiki Date: Sun, 18 Jan 2026 17:21:58 +0800 Subject: [PATCH 3/5] :sparkles: feat(render): make high-quality pen optional Signed-off-by: SimonShiki --- packages/render/src/PenSkin.js | 35 +++++++++++++++++++++- packages/render/src/RenderConstants.js | 7 ++++- packages/render/src/RenderWebGL.js | 41 +++++++++++++++++++++----- 3 files changed, 74 insertions(+), 9 deletions(-) diff --git a/packages/render/src/PenSkin.js b/packages/render/src/PenSkin.js index 87242c1b0..06cb84924 100644 --- a/packages/render/src/PenSkin.js +++ b/packages/render/src/PenSkin.js @@ -103,7 +103,15 @@ class PenSkin extends Skin { this.onCanvasSizeChanged = this.onCanvasSizeChanged.bind(this); this._renderer.on(RenderConstants.Events.CanvasSizeChanged, this.onCanvasSizeChanged); - this._canvasSize = [this._renderer.gl.canvas.width, this._renderer.gl.canvas.height]; + + this.onUseHighQualityPenChanged = this.onUseHighQualityPenChanged.bind(this); + this._renderer.on(RenderConstants.Events.UseHighQualityPenChanged, this.onUseHighQualityPenChanged); + + if (this._renderer.useHighQualityPen) { + this._canvasSize = [this._renderer.gl.canvas.width, this._renderer.gl.canvas.height]; + } else { + this._canvasSize = [...renderer.getNativeSize()]; + } this._setCanvasSize(renderer.getNativeSize()); } @@ -114,6 +122,7 @@ class PenSkin extends Skin { dispose () { this._renderer.removeListener(RenderConstants.Events.NativeSizeChanged, this.onNativeSizeChanged); this._renderer.removeListener(RenderConstants.Events.CanvasSizeChanged, this.onCanvasSizeChanged); + this._renderer.removeListener(RenderConstants.Events.UseHighQualityPenChanged, this.onUseHighQualityPenChanged); this._renderer.gl.deleteTexture(this._texture); this._texture = null; super.dispose(); @@ -309,6 +318,8 @@ class PenSkin extends Skin { * @param {object} event - The change event. */ onCanvasSizeChanged (event) { + if (!this._renderer.useHighQualityPen) return; + if (this._canvasSize[0] === event.newSize[0] && this._canvasSize[1] === event.newSize[1]) { return; } @@ -316,6 +327,24 @@ class PenSkin extends Skin { this._resetBufferSize(); } + /** + * React to a change in the high quality pen mode. + * @param {boolean} enabled - Whether high quality pen is enabled. + */ + onUseHighQualityPenChanged (enabled) { + let newSize; + if (enabled) { + newSize = [this._renderer.gl.canvas.width, this._renderer.gl.canvas.height]; + } else { + newSize = [...this._size]; + } + + if (this._canvasSize[0] !== newSize[0] || this._canvasSize[1] !== newSize[1]) { + this._canvasSize = newSize; + this._resetBufferSize(); + } + } + /** * Set the size of the pen canvas. * @param {Array} canvasSize - the new width and height for the canvas. @@ -328,6 +357,10 @@ class PenSkin extends Skin { this._rotationCenter[0] = width / 2; this._rotationCenter[1] = height / 2; + if (!this._renderer.useHighQualityPen) { + this._canvasSize = [...canvasSize]; + } + this._resetBufferSize(); } diff --git a/packages/render/src/RenderConstants.js b/packages/render/src/RenderConstants.js index 95989231b..c46ee6663 100644 --- a/packages/render/src/RenderConstants.js +++ b/packages/render/src/RenderConstants.js @@ -31,6 +31,11 @@ module.exports = { * CanvasSizeChanged event, which related to actual canvas size change. * @constant {string} */ - CanvasSizeChanged: 'CanvasSizeChanged' + CanvasSizeChanged: 'CanvasSizeChanged', + /** + * UseHighQualityPenChanged event, which related to high quality pen use change. + * @constant {string} + */ + UseHighQualityPenChanged: 'UseHighQualityPenChanged' } }; diff --git a/packages/render/src/RenderWebGL.js b/packages/render/src/RenderWebGL.js index 68e5095e3..542d0c065 100644 --- a/packages/render/src/RenderWebGL.js +++ b/packages/render/src/RenderWebGL.js @@ -180,6 +180,9 @@ class RenderWebGL extends EventEmitter { /** @type {Array} */ this._groupOrdering = []; + /** @type {boolean} */ + this._useHighQualityPen = false; + // Map of group name to layer group /** @type {Record} */ this._layerGroups = {}; @@ -263,6 +266,22 @@ class RenderWebGL extends EventEmitter { this.accurateCoordinates = value; } + /** + * Set whether to use high-quality pen. + * @param {boolean} value Whether to use high-quality pen. + */ + setHighQualityPen (value) { + this._useHighQualityPen = value; + this.emit(RenderConstants.Events.UseHighQualityPenChanged, value); + } + + /** + * @returns {boolean} Whether high-quality pen is enabled. + */ + get useHighQualityPen () { + return this._useHighQualityPen; + } + /** * Set the physical size of the stage in device-independent pixels. * This will be multiplied by the device's pixel ratio on high-DPI displays. @@ -1743,8 +1762,12 @@ class RenderWebGL extends EventEmitter { twgl.bindFramebufferInfo(gl, skin._framebuffer); // Adjust the viewport to the scaled dimensions of the framebuffer. - const scaleX = gl.canvas.width / this._nativeSize[0]; - const scaleY = gl.canvas.height / this._nativeSize[1]; + let scaleX = 1; + let scaleY = 1; + if (this._useHighQualityPen) { + scaleX = gl.canvas.width / this._nativeSize[0]; + scaleY = gl.canvas.height / this._nativeSize[1]; + } // Limit size of viewport to the bounds around the stamp Drawable and create the projection matrix for the draw. gl.viewport( @@ -1756,11 +1779,15 @@ class RenderWebGL extends EventEmitter { const projection = twgl.m4.ortho(bounds.left, bounds.right, bounds.top, bounds.bottom, -1, 1); // Draw the stamped sprite onto the PenSkin's framebuffer. - this._drawThese([stampID], ShaderManager.DRAW_MODE.default, projection, { - ignoreVisibility: true, - framebufferWidth: gl.canvas.width, - framebufferHeight: gl.canvas.height - }); + const drawOpts = { + ignoreVisibility: true + }; + if (this._useHighQualityPen) { + drawOpts.framebufferWidth = gl.canvas.width; + drawOpts.framebufferHeight = gl.canvas.height; + } + + this._drawThese([stampID], ShaderManager.DRAW_MODE.default, projection, drawOpts); skin._silhouetteDirty = true; } From 2bebcba964f3fc0cdd761ed4b39864626686e665 Mon Sep 17 00:00:00 2001 From: SimonShiki Date: Sun, 18 Jan 2026 17:39:31 +0800 Subject: [PATCH 4/5] :sparkles: feat(gui): add setting item for hqpen Signed-off-by: SimonShiki --- .../settings-modal/settings-modal.jsx | 20 +++++++++++++++++++ .../gui/src/containers/settings-modal.jsx | 8 ++++++++ packages/gui/src/lib/vm-manager-hoc.jsx | 8 ++++++++ packages/gui/src/reducers/settings.js | 1 + 4 files changed, 37 insertions(+) diff --git a/packages/gui/src/components/settings-modal/settings-modal.jsx b/packages/gui/src/components/settings-modal/settings-modal.jsx index 8ae08dbeb..7218bfdbe 100644 --- a/packages/gui/src/components/settings-modal/settings-modal.jsx +++ b/packages/gui/src/components/settings-modal/settings-modal.jsx @@ -301,6 +301,24 @@ class SettingsModal extends React.Component { onChange={this.props.onChangeAccurateCoordinates} /> +
+
+ + +
+ +
= 480) { this.props.updateSettings({stageWidth: Math.round(width)}); @@ -90,6 +94,7 @@ class SettingsModal extends React.Component { autoSaveInterval={this.props.autoSaveInterval} framerate={this.props.framerate} theme={this.props.theme} + highQualityPen={this.props.highQualityPen} infiniteCloning={this.props.infiniteCloning} edgelessStage={this.props.edgelessStage} unlimitedListLength={this.props.unlimitedListLength} @@ -105,6 +110,7 @@ class SettingsModal extends React.Component { onChangeTheme={this.handleChangeTheme} onChangeInfiniteCloning={this.handleChangeInfiniteCloning} onChangeEdgelessStage={this.handleChangeEdgelessStage} + onChangeHighQualityPen={this.handleChangeHighQualityPen} onChangeUnlimitedListLength={this.handleChangeUnlimitedListLength} onChangeUnlimitedPenSize={this.handleChangeUnlimitedPenSize} onChangeUnlimitedSoundStuffs={this.handleChangeUnlimitedSoundStuffs} @@ -120,6 +126,7 @@ class SettingsModal extends React.Component { SettingsModal.propTypes = { hideNonVanillaBlocks: PropTypes.bool.isRequired, autoSave: PropTypes.bool.isRequired, + highQualityPen: PropTypes.bool.isRequired, infiniteCloning: PropTypes.bool.isRequired, edgelessStage: PropTypes.bool.isRequired, unlimitedListLength: PropTypes.bool.isRequired, @@ -140,6 +147,7 @@ const mapStateToProps = state => ({ autoSave: state.scratchGui.settings.autoSave, infiniteCloning: state.scratchGui.settings.infiniteCloning, edgelessStage: state.scratchGui.settings.edgelessStage, + highQualityPen: state.scratchGui.settings.highQualityPen, unlimitedListLength: state.scratchGui.settings.unlimitedListLength, unlimitedPenSize: state.scratchGui.settings.unlimitedPenSize, unlimitedSoundStuffs: state.scratchGui.settings.unlimitedSoundStuffs, diff --git a/packages/gui/src/lib/vm-manager-hoc.jsx b/packages/gui/src/lib/vm-manager-hoc.jsx index b10eb3731..42bc60972 100644 --- a/packages/gui/src/lib/vm-manager-hoc.jsx +++ b/packages/gui/src/lib/vm-manager-hoc.jsx @@ -44,6 +44,9 @@ const vmManagerHOC = function (WrappedComponent) { unlimitedSoundStuffs: this.props.unlimitedSoundStuffs, accurateCoordinates: this.props.accurateCoordinates }); + this.props.vm.setStageWidth(this.props.stageWidth); + this.props.vm.setStageHeight(this.props.stageHeight); + this.props.vm.renderer.setHighQualityPen(this.props.highQualityPen); } if (!this.props.isPlayerOnly && !this.props.isStarted) { this.props.vm.start(); @@ -94,6 +97,9 @@ const vmManagerHOC = function (WrappedComponent) { accurateCoordinates: this.props.accurateCoordinates }); } + if (this.props.highQualityPen !== prevProps.highQualityPen) { + this.props.vm.renderer.setHighQualityPen(this.props.highQualityPen); + } if (this.props.stageWidth !== prevProps.stageWidth) { this.props.vm.setStageWidth(this.props.stageWidth); } @@ -175,6 +181,7 @@ const vmManagerHOC = function (WrappedComponent) { projectId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), username: PropTypes.string, framerate: PropTypes.number.isRequired, + highQualityPen: PropTypes.bool.isRequired, infiniteCloning: PropTypes.bool.isRequired, edgelessStage: PropTypes.bool.isRequired, unlimitedListLength: PropTypes.bool.isRequired, @@ -201,6 +208,7 @@ const vmManagerHOC = function (WrappedComponent) { framerate: state.scratchGui.settings.framerate, infiniteCloning: state.scratchGui.settings.infiniteCloning, edgelessStage: state.scratchGui.settings.edgelessStage, + highQualityPen: state.scratchGui.settings.highQualityPen, unlimitedListLength: state.scratchGui.settings.unlimitedListLength, unlimitedPenSize: state.scratchGui.settings.unlimitedPenSize, unlimitedSoundStuffs: state.scratchGui.settings.unlimitedSoundStuffs, diff --git a/packages/gui/src/reducers/settings.js b/packages/gui/src/reducers/settings.js index 6ccab32e3..4cd186393 100644 --- a/packages/gui/src/reducers/settings.js +++ b/packages/gui/src/reducers/settings.js @@ -5,6 +5,7 @@ const defaultState = { autoSave: false, infiniteCloning: false, edgelessStage: false, + highQualityPen: false, unlimitedListLength: false, unlimitedPenSize: false, unlimitedSoundStuffs: false, From 26f8bb00623fb7539fd5b983909e83d40194b6ac Mon Sep 17 00:00:00 2001 From: SimonShiki Date: Sun, 18 Jan 2026 18:00:29 +0800 Subject: [PATCH 5/5] :bug: fix(gui): tests Signed-off-by: SimonShiki --- packages/gui/test/unit/util/vm-manager-hoc.test.jsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/gui/test/unit/util/vm-manager-hoc.test.jsx b/packages/gui/test/unit/util/vm-manager-hoc.test.jsx index b92065ced..b4d553c69 100644 --- a/packages/gui/test/unit/util/vm-manager-hoc.test.jsx +++ b/packages/gui/test/unit/util/vm-manager-hoc.test.jsx @@ -36,6 +36,12 @@ describe('VMManagerHOC', () => { vm.setCompatibilityMode = jest.fn(); vm.setLocale = jest.fn(); vm.start = jest.fn(); + vm.runtime.renderer = { + setHighQualityPen: jest.fn(), + setEdgelessStage: jest.fn(), + setAccurateCoordinates: jest.fn(), + setStageSize: jest.fn() + }; }); test('when it mounts in player mode, the vm is initialized but not started', () => { const Component = () => (
);