diff --git a/packages/gui/src/components/settings-modal/settings-modal.jsx b/packages/gui/src/components/settings-modal/settings-modal.jsx
index cf8684c00..f654e891c 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 376904f6e..c54509516 100644
--- a/packages/gui/src/lib/vm-manager-hoc.jsx
+++ b/packages/gui/src/lib/vm-manager-hoc.jsx
@@ -46,6 +46,7 @@ const vmManagerHOC = function (WrappedComponent) {
});
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();
@@ -96,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);
}
@@ -177,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,
@@ -203,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.ts b/packages/gui/src/reducers/settings.ts
index c5de5922f..f70e2c405 100644
--- a/packages/gui/src/reducers/settings.ts
+++ b/packages/gui/src/reducers/settings.ts
@@ -8,6 +8,7 @@ export type SettingsState = {
autoSave: boolean;
infiniteCloning: boolean;
edgelessStage: boolean;
+ highQualityPen: boolean,
unlimitedListLength: boolean;
unlimitedPenSize: boolean;
unlimitedSoundStuffs: boolean;
@@ -25,6 +26,7 @@ const defaultState: SettingsState = {
autoSave: false,
infiniteCloning: false,
edgelessStage: false,
+ highQualityPen: false,
unlimitedListLength: false,
unlimitedPenSize: false,
unlimitedSoundStuffs: false,
@@ -55,6 +57,7 @@ const parseSavedSettings = (): Partial => {
autoSave: typeof parsed.autoSave === 'boolean' ? parsed.autoSave : undefined,
infiniteCloning: typeof parsed.infiniteCloning === 'boolean' ? parsed.infiniteCloning : undefined,
edgelessStage: typeof parsed.edgelessStage === 'boolean' ? parsed.edgelessStage : undefined,
+ highQualityPen: typeof parsed.highQualityPen === 'boolean' ? parsed.highQualityPen : undefined,
unlimitedListLength: typeof parsed.unlimitedListLength === 'boolean' ? parsed.unlimitedListLength : undefined,
unlimitedPenSize: typeof parsed.unlimitedPenSize === 'boolean' ? parsed.unlimitedPenSize : undefined,
unlimitedSoundStuffs:
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 = () => ();
diff --git a/packages/render/src/PenSkin.js b/packages/render/src/PenSkin.js
index 9e737652c..06cb84924 100644
--- a/packages/render/src/PenSkin.js
+++ b/packages/render/src/PenSkin.js
@@ -101,6 +101,18 @@ 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.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());
}
@@ -109,6 +121,8 @@ 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();
@@ -191,7 +205,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 +213,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 +271,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 +313,38 @@ 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._renderer.useHighQualityPen) return;
+
+ if (this._canvasSize[0] === event.newSize[0] && this._canvasSize[1] === event.newSize[1]) {
+ return;
+ }
+ this._canvasSize = event.newSize;
+ 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.
@@ -295,8 +357,24 @@ class PenSkin extends Skin {
this._rotationCenter[0] = width / 2;
this._rotationCenter[1] = height / 2;
+ if (!this._renderer.useHighQualityPen) {
+ this._canvasSize = [...canvasSize];
+ }
+
+ 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 +392,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 +438,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..c46ee6663 100644
--- a/packages/render/src/RenderConstants.js
+++ b/packages/render/src/RenderConstants.js
@@ -23,8 +23,19 @@ 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',
+ /**
+ * 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 5816a7e0c..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 = {};
@@ -196,9 +199,6 @@ class RenderWebGL extends EventEmitter {
/** @type {ShaderManager} */
this._shaderManager = new ShaderManager(gl);
- /** @type {HTMLCanvasElement} */
- this._tempCanvas = document.createElement('canvas');
-
/** @type {any} */
this._regionId = null;
@@ -266,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.
@@ -283,6 +299,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();
}
@@ -1742,17 +1761,33 @@ class RenderWebGL extends EventEmitter {
const gl = this._gl;
twgl.bindFramebufferInfo(gl, skin._framebuffer);
+ // Adjust the viewport to the scaled dimensions of the framebuffer.
+ 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(
- (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});
+ 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;
}