diff --git a/package.json b/package.json index 5a912d8d6..0e8b75caf 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "eslint": "^4.6.1", "eslint-config-scratch": "^5.0.0", "gh-pages": "^1.0.0", - "jsdoc": "^3.5.5", + "jsdoc": "^3.6.0", "json": "^9.0.4", "playwright-chromium": "^1.0.1", "scratch-vm": "0.2.0-prerelease.20200622143012", diff --git a/src/Drawable.js b/src/Drawable.js index 68eb83b03..c04d102e8 100644 --- a/src/Drawable.js +++ b/src/Drawable.js @@ -13,6 +13,7 @@ const log = require('./util/log'); * @type {twgl.v3} */ const __isTouchingPosition = twgl.v3.create(); +const FLOATING_POINT_ERROR_ALLOWANCE = 1e-6; /** * Convert a scratch space location into a texture space float. Uses the @@ -37,6 +38,11 @@ const getLocalPosition = (drawable, vec) => { // localPosition matches that transformation. localPosition[0] = 0.5 - (((v0 * m[0]) + (v1 * m[4]) + m[12]) / d); localPosition[1] = (((v0 * m[1]) + (v1 * m[5]) + m[13]) / d) + 0.5; + // Fix floating point issues near 0. Filed https://github.com/LLK/scratch-render/issues/688 that + // they're happening in the first place. + // TODO: Check if this can be removed after render pull 479 is merged + if (Math.abs(localPosition[0]) < FLOATING_POINT_ERROR_ALLOWANCE) localPosition[0] = 0; + if (Math.abs(localPosition[1]) < FLOATING_POINT_ERROR_ALLOWANCE) localPosition[1] = 0; // Apply texture effect transform if the localPosition is within the drawable's space, // and any effects are currently active. if (drawable.enabledEffects !== 0 && @@ -728,6 +734,7 @@ class Drawable { dst[3] = 0; return dst; } + const textColor = // commenting out to only use nearest for now // drawable.useNearest() ? diff --git a/src/RenderWebGL.js b/src/RenderWebGL.js index 46577aea2..df91555eb 100644 --- a/src/RenderWebGL.js +++ b/src/RenderWebGL.js @@ -188,9 +188,23 @@ class RenderWebGL extends EventEmitter { /** @type {function} */ this._exitRegion = null; + /** @type {object} */ + this._backgroundDrawRegionId = { + enter: () => this._enterDrawBackground(), + exit: () => this._exitDrawBackground() + }; + /** @type {Array.} */ this._snapshotCallbacks = []; + /** @type {Array} */ + // Don't set this directly-- use setBackgroundColor so it stays in sync with _backgroundColor3b + this._backgroundColor4f = [0, 0, 0, 1]; + + /** @type {Uint8ClampedArray} */ + // Don't set this directly-- use setBackgroundColor so it stays in sync with _backgroundColor4f + this._backgroundColor3b = new Uint8ClampedArray(3); + this._createGeometry(); this.on(RenderConstants.Events.NativeSizeChanged, this.onNativeSizeChanged); @@ -250,7 +264,14 @@ class RenderWebGL extends EventEmitter { * @param {number} blue The blue component for the background. */ setBackgroundColor (red, green, blue) { - this._backgroundColor = [red, green, blue, 1]; + this._backgroundColor4f[0] = red; + this._backgroundColor4f[1] = green; + this._backgroundColor4f[2] = blue; + + this._backgroundColor3b[0] = red * 255; + this._backgroundColor3b[1] = green * 255; + this._backgroundColor3b[2] = blue * 255; + } /** @@ -629,7 +650,7 @@ class RenderWebGL extends EventEmitter { twgl.bindFramebufferInfo(gl, null); gl.viewport(0, 0, gl.canvas.width, gl.canvas.height); - gl.clearColor.apply(gl, this._backgroundColor); + gl.clearColor.apply(gl, this._backgroundColor4f); gl.clear(gl.COLOR_BUFFER_BIT); this._drawThese(this._drawList, ShaderManager.DRAW_MODE.default, this._projection); @@ -745,12 +766,22 @@ class RenderWebGL extends EventEmitter { */ isTouchingColor (drawableID, color3b, mask3b) { const candidates = this._candidatesTouching(drawableID, this._visibleDrawList); - if (candidates.length === 0) { + + let bounds; + if (colorMatches(color3b, this._backgroundColor3b, 0)) { + // If the color we're checking for is the background color, don't confine the check to + // candidate drawables' bounds--since the background spans the entire stage, we must check + // everything that lies inside the drawable. + bounds = this._touchingBounds(drawableID); + // e.g. empty costume, or off the stage + if (bounds === null) return false; + } else if (candidates.length === 0) { + // If not checking for the background color, we can return early if there are no candidate drawables. return false; + } else { + bounds = this._candidatesBounds(candidates); } - const bounds = this._candidatesBounds(candidates); - const maxPixelsForCPU = this._getMaxPixelsForCPU(); const debugCanvasContext = this._debugCanvas && this._debugCanvas.getContext('2d'); @@ -811,6 +842,19 @@ class RenderWebGL extends EventEmitter { } } + _enterDrawBackground () { + const gl = this.gl; + const currentShader = this._shaderManager.getShader(ShaderManager.DRAW_MODE.background, 0); + gl.disable(gl.BLEND); + gl.useProgram(currentShader.program); + twgl.setBuffersAndAttributes(gl, currentShader, this._bufferInfo); + } + + _exitDrawBackground () { + const gl = this.gl; + gl.enable(gl.BLEND); + } + _isTouchingColorGpuStart (drawableID, candidateIDs, bounds, color3b, mask3b) { this._doExitDrawRegion(); @@ -822,15 +866,8 @@ class RenderWebGL extends EventEmitter { gl.viewport(0, 0, bounds.width, bounds.height); const projection = twgl.m4.ortho(bounds.left, bounds.right, bounds.top, bounds.bottom, -1, 1); - let fillBackgroundColor = this._backgroundColor; - - // When using masking such that the background fill color will showing through, ensure we don't - // fill using the same color that we are trying to detect! - if (color3b[0] > 196 && color3b[1] > 196 && color3b[2] > 196) { - fillBackgroundColor = [0, 0, 0, 255]; - } - - gl.clearColor.apply(gl, fillBackgroundColor); + // Clear the query buffer to fully transparent. This will be the color of pixels that fail the stencil test. + gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT | gl.STENCIL_BUFFER_BIT); let extraUniforms; @@ -842,6 +879,9 @@ class RenderWebGL extends EventEmitter { } try { + // Using the stencil buffer, mask out the drawing to either the drawable's alpha channel + // or pixels of the drawable which match the mask color, depending on whether a mask color is given. + // Masked-out pixels will not be checked. gl.enable(gl.STENCIL_TEST); gl.stencilFunc(gl.ALWAYS, 1, 1); gl.stencilOp(gl.KEEP, gl.KEEP, gl.REPLACE); @@ -862,12 +902,25 @@ class RenderWebGL extends EventEmitter { gl.stencilOp(gl.KEEP, gl.KEEP, gl.KEEP); gl.colorMask(true, true, true, true); + // Draw the background as a quad. Drawing a background with gl.clear will not mask to the stenciled area. + this.enterDrawRegion(this._backgroundDrawRegionId); + + const uniforms = { + u_backgroundColor: this._backgroundColor4f + }; + + const currentShader = this._shaderManager.getShader(ShaderManager.DRAW_MODE.background, 0); + twgl.setUniforms(currentShader, uniforms); + twgl.drawBufferInfo(gl, this._bufferInfo, gl.TRIANGLES); + + // Draw the candidate drawables on top of the background. this._drawThese(candidateIDs, ShaderManager.DRAW_MODE.default, projection, {idFilterFunc: testID => testID !== drawableID} ); } finally { gl.colorMask(true, true, true, true); gl.disable(gl.STENCIL_TEST); + this._doExitDrawRegion(); } } @@ -886,7 +939,8 @@ class RenderWebGL extends EventEmitter { } for (let pixelBase = 0; pixelBase < pixels.length; pixelBase += 4) { - if (colorMatches(color3b, pixels, pixelBase)) { + // Transparent pixels are masked (either by the drawable's alpha channel or color mask). + if (pixels[pixelBase + 3] !== 0 && colorMatches(color3b, pixels, pixelBase)) { return true; } } @@ -1321,7 +1375,7 @@ class RenderWebGL extends EventEmitter { gl.viewport(0, 0, bounds.width, bounds.height); const projection = twgl.m4.ortho(bounds.left, bounds.right, bounds.top, bounds.bottom, -1, 1); - gl.clearColor.apply(gl, this._backgroundColor); + gl.clearColor.apply(gl, this._backgroundColor4f); gl.clear(gl.COLOR_BUFFER_BIT); this._drawThese(this._drawList, ShaderManager.DRAW_MODE.default, projection); @@ -1411,6 +1465,13 @@ class RenderWebGL extends EventEmitter { // Update the CPU position data drawable.updateCPURenderAttributes(); const candidateBounds = drawable.getFastBounds(); + + // Push bounds out to integers. If a drawable extends out into half a pixel, that half-pixel still + // needs to be tested. Plus, in some areas we construct another rectangle from the union of these, + // and iterate over its pixels (width * height). Turns out that doesn't work so well when the + // width/height aren't integers. + candidateBounds.snapToInt(); + if (bounds.intersects(candidateBounds)) { result.push({ id, diff --git a/src/ShaderManager.js b/src/ShaderManager.js index 8c59d3b7d..37c24346d 100644 --- a/src/ShaderManager.js +++ b/src/ShaderManager.js @@ -176,7 +176,12 @@ ShaderManager.DRAW_MODE = { /** * Draw a line with caps. */ - line: 'line' + line: 'line', + + /** + * Draw the background in a certain color. Must sometimes be used instead of gl.clear. + */ + background: 'background' }; module.exports = ShaderManager; diff --git a/src/Silhouette.js b/src/Silhouette.js index 08e7120e5..b96348a81 100644 --- a/src/Silhouette.js +++ b/src/Silhouette.js @@ -10,6 +10,11 @@ */ let __SilhouetteUpdateCanvas; +// Optimized Math.min and Math.max for integers; +// taken from https://web.archive.org/web/20190716181049/http://guihaire.com/code/?p=549 +const intMin = (i, j) => j ^ ((i ^ j) & ((i - j) >> 31)); +const intMax = (i, j) => i ^ ((i ^ j) & ((i - j) >> 31)); + /** * Internal helper function (in hopes that compiler can inline). Get a pixel * from silhouette data, or 0 if outside it's bounds. @@ -40,13 +45,18 @@ const __cornerWork = [ /** * Get the color from a given silhouette at an x/y local texture position. * Multiply color values by alpha for proper blending. - * @param {Silhouette} The silhouette to sample. - * @param {number} x X position of texture (0-1). - * @param {number} y Y position of texture (0-1). + * @param {Silhouette} $0 The silhouette to sample. + * @param {number} x X position of texture [0, width). + * @param {number} y Y position of texture [0, height). * @param {Uint8ClampedArray} dst A color 4b space. * @return {Uint8ClampedArray} The dst vector. */ const getColor4b = ({_width: width, _height: height, _colorData: data}, x, y, dst) => { + // Clamp coords to edge, matching GL_CLAMP_TO_EDGE. + // (See github.com/LLK/scratch-render/blob/954cfff02b08069a082cbedd415c1fecd9b1e4fb/src/BitmapSkin.js#L88) + x = intMax(0, intMin(x, width - 1)); + y = intMax(0, intMin(y, height - 1)); + // 0 if outside bounds, otherwise read from data. if (x >= width || y >= height || x < 0 || y < 0) { return dst.fill(0); @@ -64,17 +74,17 @@ const getColor4b = ({_width: width, _height: height, _colorData: data}, x, y, ds /** * Get the color from a given silhouette at an x/y local texture position. * Do not multiply color values by alpha, as it has already been done. - * @param {Silhouette} The silhouette to sample. - * @param {number} x X position of texture (0-1). - * @param {number} y Y position of texture (0-1). + * @param {Silhouette} $0 The silhouette to sample. + * @param {number} x X position of texture [0, width). + * @param {number} y Y position of texture [0, height). * @param {Uint8ClampedArray} dst A color 4b space. * @return {Uint8ClampedArray} The dst vector. */ const getPremultipliedColor4b = ({_width: width, _height: height, _colorData: data}, x, y, dst) => { - // 0 if outside bounds, otherwise read from data. - if (x >= width || y >= height || x < 0 || y < 0) { - return dst.fill(0); - } + // Clamp coords to edge, matching GL_CLAMP_TO_EDGE. + x = intMax(0, intMin(x, width - 1)); + y = intMax(0, intMin(y, height - 1)); + const offset = ((y * width) + x) * 4; dst[0] = data[offset]; dst[1] = data[offset + 1]; diff --git a/src/shaders/sprite.frag b/src/shaders/sprite.frag index f13005901..da286883d 100644 --- a/src/shaders/sprite.frag +++ b/src/shaders/sprite.frag @@ -39,9 +39,15 @@ uniform float u_lineThickness; uniform float u_lineLength; #endif // DRAW_MODE_line +#ifdef DRAW_MODE_background +uniform vec4 u_backgroundColor; +#endif // DRAW_MODE_background + uniform sampler2D u_skin; +#ifndef DRAW_MODE_background varying vec2 v_texCoord; +#endif // Add this to divisors to prevent division by 0, which results in NaNs propagating through calculations. // Smaller values can cause problems on some mobile devices. @@ -109,7 +115,7 @@ const vec2 kCenter = vec2(0.5, 0.5); void main() { - #ifndef DRAW_MODE_line + #if !(defined(DRAW_MODE_line) || defined(DRAW_MODE_background)) vec2 texcoord0 = v_texCoord; #ifdef ENABLE_mosaic @@ -215,7 +221,9 @@ void main() gl_FragColor.rgb /= gl_FragColor.a + epsilon; #endif - #else // DRAW_MODE_line + #endif // !(defined(DRAW_MODE_line) || defined(DRAW_MODE_background)) + + #ifdef DRAW_MODE_line // Maaaaagic antialiased-line-with-round-caps shader. // "along-the-lineness". This increases parallel to the line. @@ -234,4 +242,8 @@ void main() // the closer we are to the line, invert it. gl_FragColor = u_lineColor * clamp(1.0 - line, 0.0, 1.0); #endif // DRAW_MODE_line + + #ifdef DRAW_MODE_background + gl_FragColor = u_backgroundColor; + #endif } diff --git a/src/shaders/sprite.vert b/src/shaders/sprite.vert index ffc5150f0..24437c3fc 100644 --- a/src/shaders/sprite.vert +++ b/src/shaders/sprite.vert @@ -14,7 +14,7 @@ uniform vec4 u_penPoints; const float epsilon = 1e-3; #endif -#ifndef DRAW_MODE_line +#if !(defined(DRAW_MODE_line) || defined(DRAW_MODE_background)) uniform mat4 u_projectionMatrix; uniform mat4 u_modelMatrix; attribute vec2 a_texCoord; @@ -66,6 +66,8 @@ void main() { // 4. Apply view transform position *= 2.0 / u_stageSize; gl_Position = vec4(position, 0, 1); + #elif defined(DRAW_MODE_background) + gl_Position = vec4(a_position * 2.0, 0, 1); #else gl_Position = u_projectionMatrix * u_modelMatrix * vec4(a_position, 0, 1); v_texCoord = a_texCoord; diff --git a/test/integration/scratch-tests/clear-color.sb3 b/test/integration/scratch-tests/clear-color.sb3 new file mode 100644 index 000000000..42b6d8516 Binary files /dev/null and b/test/integration/scratch-tests/clear-color.sb3 differ