From c54a928f0a2fa0686c5a943671e4fcca09cfd4d9 Mon Sep 17 00:00:00 2001 From: Mx Corey Frang <gnarf37@gmail.com> Date: Wed, 8 Aug 2018 14:20:35 -0400 Subject: [PATCH] Touching color implementation (#312) * Touching color draft implementation * New constant for CPU/GPU render split determined, removing query param * Small fix for pick tests --- src/Drawable.js | 109 ++++++++++++----- src/EffectTransform.js | 172 +++++++++++++++++++++++++-- src/RenderWebGL.js | 193 +++++++++++++++++++++++-------- src/Silhouette.js | 91 ++++++++++++++- src/Skin.js | 2 - test/integration/cpu-render.html | 86 ++++++++++++++ test/integration/index.html | 1 + test/integration/pick-tests.js | 8 +- 8 files changed, 574 insertions(+), 88 deletions(-) create mode 100644 test/integration/cpu-render.html diff --git a/src/Drawable.js b/src/Drawable.js index db6bdcc8..ba752bc6 100644 --- a/src/Drawable.js +++ b/src/Drawable.js @@ -6,8 +6,41 @@ const ShaderManager = require('./ShaderManager'); const Skin = require('./Skin'); const EffectTransform = require('./EffectTransform'); +/** + * An internal workspace for calculating texture locations from world vectors + * this is REUSED for memory conservation reasons + * @type {twgl.v3} + */ const __isTouchingPosition = twgl.v3.create(); +/** + * Convert a scratch space location into a texture space float. Uses the + * internal __isTouchingPosition as a return value, so this should be copied + * if you ever need to get two local positions and store both. Requires that + * the drawable inverseMatrix is up to date. + * + * @param {Drawable} drawable The drawable to get the inverse matrix and uniforms from + * @param {twgl.v3} vec [x,y] scratch space vector + * @return {twgl.v3} [x,y] texture space float vector - transformed by effects and matrix + */ +const getLocalPosition = (drawable, vec) => { + // Transfrom from world coordinates to Drawable coordinates. + const localPosition = __isTouchingPosition; + const v0 = vec[0]; + const v1 = vec[1]; + const m = drawable._inverseMatrix; + // var v2 = v[2]; + const d = (v0 * m[3]) + (v1 * m[7]) + m[15]; + // The RenderWebGL quad flips the texture's X axis. So rendered bottom + // left is 1, 0 and the top right is 0, 1. Flip the X axis so + // 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; + // Apply texture effect transform. + EffectTransform.transformPoint(drawable, localPosition, localPosition); + return localPosition; +}; + class Drawable { /** * An object which can be drawn by the renderer. @@ -381,36 +414,7 @@ class Drawable { return false; } - if (this._transformDirty) { - this._calculateTransform(); - } - - // Get the inverse of the model matrix or update it. - const inverse = this._inverseMatrix; - if (this._inverseTransformDirty) { - const model = twgl.m4.copy(this._uniforms.u_modelMatrix, inverse); - // The normal matrix uses a z scaling of 0 causing model[10] to be - // 0. Getting a 4x4 inverse is impossible without a scaling in x, y, - // and z. - model[10] = 1; - twgl.m4.inverse(model, model); - this._inverseTransformDirty = false; - } - - // Transfrom from world coordinates to Drawable coordinates. - const localPosition = twgl.m4.transformPoint(inverse, vec, __isTouchingPosition); - - // Transform into texture coordinates. 0, 0 is the bottom left. 1, 1 is - // the top right. - localPosition[0] += 0.5; - localPosition[1] += 0.5; - // The RenderWebGL quad flips the texture's X axis. So rendered bottom - // left is 1, 0 and the top right is 0, 1. Flip the X axis so - // localPosition matches that transformation. - localPosition[0] = 1 - localPosition[0]; - - // Apply texture effect transform. - EffectTransform.transformPoint(this, localPosition, localPosition); + const localPosition = getLocalPosition(this, vec); if (this.useNearest) { return this.skin.isTouchingNearest(localPosition); @@ -460,6 +464,7 @@ class Drawable { bounds.initFromPointsAABB(transformedHullPoints); return bounds; } + /** * Get the precise bounds for the upper 8px slice of the Drawable. * Used for calculating where to position a text bubble. @@ -514,6 +519,7 @@ class Drawable { * @return {!Rectangle} Bounds for the Drawable. */ getFastBounds () { + this.updateMatrix(); if (!this.needsConvexHullPoints()) { return this.getBounds(); } @@ -545,6 +551,27 @@ class Drawable { return transformedHullPoints; } + /** + * Update the transform matrix and calculate it's inverse for collision + * and local texture position purposes. + */ + updateMatrix () { + if (this._transformDirty) { + this._calculateTransform(); + } + // Get the inverse of the model matrix or update it. + if (this._inverseTransformDirty) { + const inverse = this._inverseMatrix; + twgl.m4.copy(this._uniforms.u_modelMatrix, inverse); + // The normal matrix uses a z scaling of 0 causing model[10] to be + // 0. Getting a 4x4 inverse is impossible without a scaling in x, y, + // and z. + inverse[10] = 1; + twgl.m4.inverse(inverse, inverse); + this._inverseTransformDirty = false; + } + } + /** * Respond to an internal change in the current Skin. * @private @@ -586,6 +613,28 @@ class Drawable { id |= (b & 255) << 16; return id + RenderConstants.ID_NONE; } + + /** + * Sample a color from a drawable's texture. + * @param {twgl.v3} vec The scratch space [x,y] vector + * @param {Drawable} drawable The drawable to sample the texture from + * @param {Uint8ClampedArray} dst The "color4b" representation of the texture at point. + * @returns {Uint8ClampedArray} The dst object filled with the color4b + */ + static sampleColor4b (vec, drawable, dst) { + const localPosition = getLocalPosition(drawable, vec); + if (localPosition[0] < 0 || localPosition[1] < 0 || + localPosition[0] > 1 || localPosition[1] > 1) { + dst[3] = 0; + return dst; + } + const textColor = + // commenting out to only use nearest for now + // drawable.useNearest ? + drawable.skin._silhouette.colorAtNearest(localPosition, dst); + // : drawable.skin._silhouette.colorAtLinear(localPosition, dst); + return EffectTransform.transformColor(drawable, textColor, textColor); + } } module.exports = Drawable; diff --git a/src/EffectTransform.js b/src/EffectTransform.js index fad59e65..7b04a27d 100644 --- a/src/EffectTransform.js +++ b/src/EffectTransform.js @@ -20,7 +20,165 @@ const CENTER_X = 0.5; */ const CENTER_Y = 0.5; +// color conversions grabbed from https://gist.github.com/mjackson/5311256 + +/** + * Converts an RGB color value to HSL. Conversion formula + * adapted from http://en.wikipedia.org/wiki/HSL_color_space. + * Assumes r, g, and b are contained in the set [0, 255] and + * returns h, s, and l in the set [0, 1]. + * + * @param {number} r The red color value + * @param {number} g The green color value + * @param {number} b The blue color value + * @return {Array} The HSL representation + */ +const rgbToHsl = ([r, g, b]) => { + r /= 255; + g /= 255; + b /= 255; + + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + let h; + let s; + const l = (max + min) / 2; + + if (max === min) { + h = s = 0; // achromatic + } else { + const d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + + switch (max) { + case r: h = ((g - b) / d) + (g < b ? 6 : 0); break; + case g: h = ((b - r) / d) + 2; break; + case b: h = ((r - g) / d) + 4; break; + } + + h /= 6; + } + + return [h, s, l]; +}; + +/** + * Helper function for hslToRgb is called with varying 't' values to get + * red green and blue values from the p/q/t color space calculations + * @param {number} p vector coordinates + * @param {number} q vector coordinates + * @param {number} t vector coordinates + * @return {number} amount of r/g/b byte + */ +const hue2rgb = (p, q, t) => { + if (t < 0) t += 1; + if (t > 1) t -= 1; + if (t < 1 / 6) return p + ((q - p) * 6 * t); + if (t < 1 / 2) return q; + if (t < 2 / 3) return p + ((q - p) * ((2 / 3) - t) * 6); + return p; +}; + + +/** + * Converts an HSL color value to RGB. Conversion formula + * adapted from http://en.wikipedia.org/wiki/HSL_color_space. + * Assumes h, s, and l are contained in the set [0, 1] and + * returns r, g, and b in the set [0, 255]. + * + * @param {number} h The hue + * @param {number} s The saturation + * @param {number} l The lightness + * @return {Array} The RGB representation + */ +const hslToRgb = ([h, s, l]) => { + let r; + let g; + let b; + + if (s === 0) { + r = g = b = l; // achromatic + } else { + + const q = l < 0.5 ? l * (1 + s) : l + s - (l * s); + const p = (2 * l) - q; + + r = hue2rgb(p, q, h + (1 / 3)); + g = hue2rgb(p, q, h); + b = hue2rgb(p, q, h - (1 / 3)); + } + + return [r * 255, g * 255, b * 255]; +}; + class EffectTransform { + + /** + * Transform a color given the drawables effect uniforms. Will apply + * Ghost and Color and Brightness effects. + * @param {Drawable} drawable The drawable to get uniforms from. + * @param {Uint8ClampedArray} color4b The initial color. + * @param {Uint8ClampedArary} [dst] Working space to save the color in (is returned) + * @param {number} [effectMask] A bitmask for which effects to use. Optional. + * @returns {Uint8ClampedArray} dst filled with the transformed color + */ + static transformColor (drawable, color4b, dst, effectMask) { + dst = dst || new Uint8ClampedArray(4); + effectMask = effectMask || 0xffffffff; + dst.set(color4b); + if (dst[3] === 0) { + return dst; + } + + const uniforms = drawable.getUniforms(); + const effects = drawable.getEnabledEffects() & effectMask; + + if ((effects & ShaderManager.EFFECT_INFO.ghost.mask) !== 0) { + // gl_FragColor.a *= u_ghost + dst[3] *= uniforms.u_ghost; + } + + const enableColor = (effects & ShaderManager.EFFECT_INFO.color.mask) !== 0; + const enableBrightness = (effects & ShaderManager.EFFECT_INFO.brightness.mask) !== 0; + + if (enableColor || enableBrightness) { + // vec3 hsl = convertRGB2HSL(gl_FragColor.xyz); + const hsl = rgbToHsl(dst); + + if (enableColor) { + // this code forces grayscale values to be slightly saturated + // so that some slight change of hue will be visible + // const float minLightness = 0.11 / 2.0; + const minL = 0.11 / 2.0; + // const float minSaturation = 0.09; + const minS = 0.09; + // if (hsl.z < minLightness) hsl = vec3(0.0, 1.0, minLightness); + if (hsl[2] < minL) { + hsl[0] = 0; + hsl[1] = 1; + hsl[2] = minL; + // else if (hsl.y < minSaturation) hsl = vec3(0.0, minSaturation, hsl.z); + } else if (hsl[1] < minS) { + hsl[0] = 0; + hsl[1] = minS; + } + + // hsl.x = mod(hsl.x + u_color, 1.0); + // if (hsl.x < 0.0) hsl.x += 1.0; + hsl[0] = (uniforms.u_color + hsl[0] + 1) % 1; + } + + if (enableBrightness) { + // hsl.z = clamp(hsl.z + u_brightness, 0.0, 1.0); + hsl[2] = Math.min(1, hsl[2] + uniforms.u_brightness); + } + // gl_FragColor.rgb = convertHSL2RGB(hsl); + dst.set(hslToRgb(hsl)); + } + + return dst; + } + /** * Transform a texture coordinate to one that would be select after applying shader effects. * @param {Drawable} drawable The drawable whose effects to emulate. @@ -56,7 +214,7 @@ class EffectTransform { const offsetX = dst[0] - CENTER_X; const offsetY = dst[1] - CENTER_Y; // float offsetMagnitude = length(offset); - const offsetMagnitude = twgl.v3.length(dst); + const offsetMagnitude = Math.sqrt(Math.pow(offsetX, 2) + Math.pow(offsetY, 2)); // float whirlFactor = max(1.0 - (offsetMagnitude / kRadius), 0.0); const whirlFactor = Math.max(1.0 - (offsetMagnitude / RADIUS), 0.0); // float whirlActual = u_whirl * whirlFactor * whirlFactor; @@ -69,14 +227,14 @@ class EffectTransform { // cosWhirl, -sinWhirl, // sinWhirl, cosWhirl // ); - const rot00 = cosWhirl; - const rot10 = -sinWhirl; - const rot01 = sinWhirl; - const rot11 = cosWhirl; + const rot1 = cosWhirl; + const rot2 = -sinWhirl; + const rot3 = sinWhirl; + const rot4 = cosWhirl; // texcoord0 = rotationMatrix * offset + kCenter; - dst[0] = (rot00 * offsetX) + (rot10 * offsetY) + CENTER_X; - dst[1] = (rot01 * offsetX) + (rot11 * offsetY) + CENTER_Y; + dst[0] = (rot1 * offsetX) + (rot3 * offsetY) + CENTER_X; + dst[1] = (rot2 * offsetX) + (rot4 * offsetY) + CENTER_Y; } if ((effects & ShaderManager.EFFECT_INFO.fisheye.mask) !== 0) { // vec2 vec = (texcoord0 - kCenter) / kCenter; diff --git a/src/RenderWebGL.js b/src/RenderWebGL.js index 8b1133ba..dbd55985 100644 --- a/src/RenderWebGL.js +++ b/src/RenderWebGL.js @@ -16,6 +16,12 @@ const log = require('./util/log'); const __isTouchingDrawablesPoint = twgl.v3.create(); const __candidatesBounds = new Rectangle(); +const __touchingColor = new Uint8ClampedArray(4); +const __blendColor = new Uint8ClampedArray(4); + +// More pixels than this and we give up to the GPU and take the cost of readPixels +// Width * Height * Number of drawables at location +const __cpuTouchingColorPixelCount = 4e4; /** * @callback RenderWebGL#idFilterFunc @@ -32,26 +38,40 @@ const __candidatesBounds = new Rectangle(); const MAX_TOUCH_SIZE = [3, 3]; /** - * "touching {color}?" or "{color} touching {color}?" tests will be true if the - * target is touching a color whose components are each within this tolerance of - * the corresponding component of the query color. - * between 0 (exact matches only) and 255 (match anything). - * @type {object.<string,int>} - * @memberof RenderWebGL + * Passed to the uniforms for mask in touching color */ -const TOLERANCE_TOUCHING_COLOR = { - R: 7, - G: 7, - B: 15, - Mask: 2 -}; +const MASK_TOUCHING_COLOR_TOLERANCE = 2; /** - * Constant used for masking when detecting the color white - * @type {Array<int>} - * @memberof RenderWebGL + * Determines if the mask color is "close enough" (only test the 6 top bits for + * each color). These bit masks are what scratch 2 used to use, so we do the same. + * @param {Uint8Array} a A color3b or color4b value. + * @param {Uint8Array} b A color3b or color4b value. + * @returns {boolean} If the colors match within the parameters. */ -const COLOR_BLACK = [0, 0, 0, 1]; +const maskMatches = (a, b) => ( + // has some non-alpha component to test against + a[3] > 0 && + (a[0] & 0b11111100) === (b[0] & 0b11111100) && + (a[1] & 0b11111100) === (b[1] & 0b11111100) && + (a[2] & 0b11111100) === (b[2] & 0b11111100) +); + +/** + * Determines if the given color is "close enough" (only test the 5 top bits for + * red and green, 4 bits for blue). These bit masks are what scratch 2 used to use, + * so we do the same. + * @param {Uint8Array} a A color3b or color4b value. + * @param {Uint8Array} b A color3b or color4b value / or a larger array when used with offsets + * @param {number} offset An offset into the `b` array, which lets you use a larger array to test + * multiple values at the same time. + * @returns {boolean} If the colors match within the parameters. + */ +const colorMatches = (a, b, offset) => ( + (a[0] & 0b11111000) === (b[offset + 0] & 0b11111000) && + (a[1] & 0b11111000) === (b[offset + 1] & 0b11111000) && + (a[2] & 0b11110000) === (b[offset + 2] & 0b11110000) +); /** * Sprite Fencing - The number of pixels a sprite is required to leave remaining @@ -667,9 +687,6 @@ class RenderWebGL extends EventEmitter { * @returns {boolean} True iff the Drawable is touching the color. */ isTouchingColor (drawableID, color3b, mask3b) { - const gl = this._gl; - twgl.bindFramebufferInfo(gl, this._queryBufferInfo); - const candidates = this._candidatesTouching(drawableID, this._visibleDrawList); if (candidates.length === 0) { return false; @@ -677,7 +694,43 @@ class RenderWebGL extends EventEmitter { const bounds = this._candidatesBounds(candidates); - const candidateIDs = candidates.map(({id}) => id); + // if there are just too many pixels to CPU render efficently, we + // need to let readPixels happen + if (bounds.width * bounds.height * (candidates.length + 1) >= __cpuTouchingColorPixelCount) { + this._isTouchingColorGpuStart(drawableID, candidates.map(({id}) => id).reverse(), bounds, color3b, mask3b); + } + + const drawable = this._allDrawables[drawableID]; + const point = __isTouchingDrawablesPoint; + const color = __touchingColor; + const hasMask = Boolean(mask3b); + + for (let y = bounds.bottom; y <= bounds.top; y++) { + if (bounds.width * (y - bounds.bottom) * (candidates.length + 1) >= __cpuTouchingColorPixelCount) { + return this._isTouchingColorGpuFin(bounds, color3b, y - bounds.bottom); + } + // Scratch Space - +y is top + for (let x = bounds.left; x <= bounds.right; x++) { + point[1] = y; + point[0] = x; + if ( + // if we use a mask, check our sample color + (hasMask ? + maskMatches(Drawable.sampleColor4b(point, drawable, color), mask3b) : + drawable.isTouching(point)) && + // and the target color is drawn at this pixel + colorMatches(RenderWebGL.sampleColor3b(point, candidates, color), color3b, 0) + ) { + return true; + } + } + } + return false; + } + + _isTouchingColorGpuStart (drawableID, candidateIDs, bounds, color3b, mask3b) { + const gl = this._gl; + twgl.bindFramebufferInfo(gl, this._queryBufferInfo); // Limit size of viewport to the bounds around the target Drawable, // and create the projection matrix for the draw. @@ -689,7 +742,7 @@ class RenderWebGL extends EventEmitter { // 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 = COLOR_BLACK; + fillBackgroundColor = [0, 0, 0, 255]; } gl.clearColor.apply(gl, fillBackgroundColor); @@ -699,7 +752,7 @@ class RenderWebGL extends EventEmitter { if (mask3b) { extraUniforms = { u_colorMask: [mask3b[0] / 255, mask3b[1] / 255, mask3b[2] / 255], - u_colorMaskTolerance: TOLERANCE_TOUCHING_COLOR.Mask / 255 + u_colorMaskTolerance: MASK_TOUCHING_COLOR_TOLERANCE / 255 }; } @@ -730,27 +783,24 @@ class RenderWebGL extends EventEmitter { gl.colorMask(true, true, true, true); gl.disable(gl.STENCIL_TEST); } + } - const pixels = new Uint8Array(Math.floor(bounds.width * bounds.height * 4)); - gl.readPixels(0, 0, bounds.width, bounds.height, gl.RGBA, gl.UNSIGNED_BYTE, pixels); + _isTouchingColorGpuFin (bounds, color3b, stop) { + const gl = this._gl; + const pixels = new Uint8Array(Math.floor(bounds.width * (bounds.height - stop) * 4)); + gl.readPixels(0, 0, bounds.width, (bounds.height - stop), gl.RGBA, gl.UNSIGNED_BYTE, pixels); if (this._debugCanvas) { this._debugCanvas.width = bounds.width; this._debugCanvas.height = bounds.height; const context = this._debugCanvas.getContext('2d'); - const imageData = context.getImageData(0, 0, bounds.width, bounds.height); + const imageData = context.getImageData(0, 0, bounds.width, bounds.height - stop); imageData.data.set(pixels); context.putImageData(imageData, 0, 0); } for (let pixelBase = 0; pixelBase < pixels.length; pixelBase += 4) { - const pixelDistanceR = Math.abs(pixels[pixelBase] - color3b[0]); - const pixelDistanceG = Math.abs(pixels[pixelBase + 1] - color3b[1]); - const pixelDistanceB = Math.abs(pixels[pixelBase + 2] - color3b[2]); - - if (pixelDistanceR <= TOLERANCE_TOUCHING_COLOR.R && - pixelDistanceG <= TOLERANCE_TOUCHING_COLOR.G && - pixelDistanceB <= TOLERANCE_TOUCHING_COLOR.B) { + if (colorMatches(color3b, pixels, pixelBase)) { return true; } } @@ -883,7 +933,12 @@ class RenderWebGL extends EventEmitter { candidateIDs = (candidateIDs || this._drawList).filter(id => { const drawable = this._allDrawables[id]; // default pick list ignores visible and ghosted sprites. - return drawable.getVisible() && drawable.getUniforms().u_ghost !== 0; + if (drawable.getVisible() && drawable.getUniforms().u_ghost !== 0) { + drawable.updateMatrix(); + drawable.skin.updateSilhouette(); + return true; + } + return false; }); if (candidateIDs.length === 0) { return false; @@ -1085,6 +1140,8 @@ class RenderWebGL extends EventEmitter { /** @todo remove this once URL-based skin setting is removed. */ if (!drawable.skin || !drawable.skin.getTexture([100, 100])) return null; + drawable.updateMatrix(); + drawable.skin.updateSilhouette(); const bounds = drawable.getFastBounds(); // Limit queries to the stage size. @@ -1110,24 +1167,31 @@ class RenderWebGL extends EventEmitter { */ _candidatesTouching (drawableID, candidateIDs) { const bounds = this._touchingBounds(drawableID); - if (!bounds) { - return []; + const result = []; + if (bounds === null) { + return result; } - return candidateIDs.reduce((result, id) => { + // iterate through the drawables list BACKWARDS - we want the top most item to be the first we check + for (let index = candidateIDs.length - 1; index >= 0; index--) { + const id = candidateIDs[index]; if (id !== drawableID) { const drawable = this._allDrawables[id]; - const candidateBounds = drawable.getFastBounds(); - - if (bounds.intersects(candidateBounds)) { - result.push({ - id, - drawable, - intersection: Rectangle.intersect(bounds, candidateBounds) - }); + if (drawable.skin && drawable._visible) { + // Update the CPU position data + drawable.updateMatrix(); + drawable.skin.updateSilhouette(); + const candidateBounds = drawable.getFastBounds(); + if (bounds.intersects(candidateBounds)) { + result.push({ + id, + drawable, + intersection: Rectangle.intersect(bounds, candidateBounds) + }); + } } } - return result; - }, []); + } + return result; } /** @@ -1556,6 +1620,43 @@ class RenderWebGL extends EventEmitter { // Simplify boundary points using convex hull. return hull(boundaryPoints, Infinity); } + + /** + * Sample a "final" color from an array of drawables at a given scratch space. + * Will blend any alpha values with the drawables "below" it. + * @param {twgl.v3} vec Scratch Vector Space to sample + * @param {Array<Drawables>} drawables A list of drawables with the "top most" + * drawable at index 0 + * @param {Uint8ClampedArray} dst The color3b space to store the answer in. + * @return {Uint8ClampedArray} The dst vector with everything blended down. + */ + static sampleColor3b (vec, drawables, dst) { + dst = dst || new Uint8ClampedArray(3); + dst.fill(0); + let blendAlpha = 1; + for (let index = 0; blendAlpha !== 0 && index < drawables.length; index++) { + /* + if (left > vec[0] || right < vec[0] || + bottom > vec[1] || top < vec[0]) { + continue; + } + */ + Drawable.sampleColor4b(vec, drawables[index].drawable, __blendColor); + // if we are fully transparent, go to the next one "down" + const sampleAlpha = __blendColor[3] / 255; + // premultiply alpha + dst[0] += __blendColor[0] * blendAlpha * sampleAlpha; + dst[1] += __blendColor[1] * blendAlpha * sampleAlpha; + dst[2] += __blendColor[2] * blendAlpha * sampleAlpha; + blendAlpha *= (1 - sampleAlpha); + } + // Backdrop could be transparent, so we need to go to the "clear color" of the + // draw scene (white) as a fallback if everything was alpha + dst[0] += blendAlpha * 255; + dst[1] += blendAlpha * 255; + dst[2] += blendAlpha * 255; + return dst; + } } // :3 diff --git a/src/Silhouette.js b/src/Silhouette.js index 22642711..413d3b5b 100644 --- a/src/Silhouette.js +++ b/src/Silhouette.js @@ -27,6 +27,37 @@ const getPoint = ({_width: width, _height: height, _data: data}, x, y) => { return data[(y * width) + x]; }; +/** + * Memory buffers for doing 4 corner sampling for linear interpolation + */ +const __cornerWork = [ + new Uint8ClampedArray(4), + new Uint8ClampedArray(4), + new Uint8ClampedArray(4), + new Uint8ClampedArray(4) +]; + +/** + * Get the color from a given silhouette at an x/y local texture position. + * @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 {Uint8ClampedArray} dst A color 4b space. + * @return {Uint8ClampedArray} The dst vector. + */ +const getColor4b = ({_width: width, _height: height, _colorData: data}, x, y, dst) => { + // 0 if outside bouds, otherwise read from data. + if (x >= width || y >= height || x < 0 || y < 0) { + return dst.fill(0); + } + const offset = ((y * width) + x) * 4; + dst[0] = data[offset]; + dst[1] = data[offset + 1]; + dst[2] = data[offset + 2]; + dst[3] = data[offset + 3]; + return dst; +}; + class Silhouette { constructor () { /** @@ -46,6 +77,9 @@ class Silhouette { * @type {Uint8ClampedArray} */ this._data = null; + this._colorData = null; + + this.colorAtNearest = this.colorAtLinear = (_, dst) => dst.fill(0); } /** @@ -67,12 +101,65 @@ class Silhouette { const imageData = ctx.getImageData(0, 0, width, height); this._data = new Uint8ClampedArray(imageData.data.length / 4); + this._colorData = imageData.data; + // delete our custom overriden "uninitalized" color functions + // let the prototype work for itself + delete this.colorAtNearest; + delete this.colorAtLinear; for (let i = 0; i < imageData.data.length; i += 4) { this._data[i / 4] = imageData.data[i + 3]; } } + /** + * Sample a color from the silhouette at a given local position using + * "nearest neighbor" + * @param {twgl.v3} vec [x,y] texture space (0-1) + * @param {Uint8ClampedArray} dst The memory buffer to store the value in. (4 bytes) + * @returns {Uint8ClampedArray} dst + */ + colorAtNearest (vec, dst) { + return getColor4b( + this, + Math.floor(vec[0] * (this._width - 1)), + Math.floor(vec[1] * (this._height - 1)), + dst + ); + } + + /** + * Sample a color from the silhouette at a given local position using + * "linear interpolation" + * @param {twgl.v3} vec [x,y] texture space (0-1) + * @param {Uint8ClampedArray} dst The memory buffer to store the value in. (4 bytes) + * @returns {Uint8ClampedArray} dst + */ + colorAtLinear (vec, dst) { + const x = vec[0] * (this._width - 1); + const y = vec[1] * (this._height - 1); + + const x1D = x % 1; + const y1D = y % 1; + const x0D = 1 - x1D; + const y0D = 1 - y1D; + + const xFloor = Math.floor(x); + const yFloor = Math.floor(y); + + const x0y0 = getColor4b(this, xFloor, yFloor, __cornerWork[0]); + const x1y0 = getColor4b(this, xFloor + 1, yFloor, __cornerWork[1]); + const x0y1 = getColor4b(this, xFloor, yFloor + 1, __cornerWork[2]); + const x1y1 = getColor4b(this, xFloor + 1, yFloor + 1, __cornerWork[3]); + + dst[0] = (x0y0[0] * x0D * y0D) + (x0y1[0] * x0D * y1D) + (x1y0[0] * x1D * y0D) + (x1y1[0] * x1D * y1D); + dst[1] = (x0y0[1] * x0D * y0D) + (x0y1[1] * x0D * y1D) + (x1y0[1] * x1D * y0D) + (x1y1[1] * x1D * y1D); + dst[2] = (x0y0[2] * x0D * y0D) + (x0y1[2] * x0D * y1D) + (x1y0[2] * x1D * y0D) + (x1y1[2] * x1D * y1D); + dst[3] = (x0y0[3] * x0D * y0D) + (x0y1[3] * x0D * y1D) + (x1y0[3] * x1D * y0D) + (x1y1[3] * x1D * y1D); + + return dst; + } + /** * Test if texture coordinate touches the silhouette using nearest neighbor. * @param {twgl.v3} vec A texture coordinate. @@ -82,8 +169,8 @@ class Silhouette { if (!this._data) return; return getPoint( this, - Math.round(vec[0] * (this._width - 1)), - Math.round(vec[1] * (this._height - 1)) + Math.floor(vec[0] * (this._width - 1)), + Math.floor(vec[1] * (this._height - 1)) ) > 0; } diff --git a/src/Skin.js b/src/Skin.js index 0e0b2ac8..1e180c03 100644 --- a/src/Skin.js +++ b/src/Skin.js @@ -161,7 +161,6 @@ class Skin extends EventEmitter { * @return {boolean} Did it touch? */ isTouchingNearest (vec) { - this.updateSilhouette(); return this._silhouette.isTouchingNearest(vec); } @@ -172,7 +171,6 @@ class Skin extends EventEmitter { * @return {boolean} Did it touch? */ isTouchingLinear (vec) { - this.updateSilhouette(); return this._silhouette.isTouchingLinear(vec); } diff --git a/test/integration/cpu-render.html b/test/integration/cpu-render.html new file mode 100644 index 00000000..2fecd754 --- /dev/null +++ b/test/integration/cpu-render.html @@ -0,0 +1,86 @@ +<body> + <script src="../../node_modules/scratch-vm/dist/web/scratch-vm.js"></script> + <script src="../../node_modules/scratch-storage/dist/web/scratch-storage.js"></script> + <!-- note: this uses the BUILT version of scratch-render! make sure to npm run build --> + <script src="../../dist/web/scratch-render.js"></script> + + <canvas id="test" width="480" height="360"></canvas> + <canvas id="cpu" width="480" height="360"></canvas> + <br/> + <canvas id="merge" width="480" height="360"></canvas> + <input type="file" id="file" name="file"> + + <script> + // These variables are going to be available in the "window global" intentionally. + // Allows you easy access to debug with `vm.greenFlag()` etc. + window.devicePixelRatio = 1; + const gpuCanvas = document.getElementById('test'); + var render = new ScratchRender(gpuCanvas); + var vm = new VirtualMachine(); + var storage = new ScratchStorage(); + + vm.attachStorage(storage); + vm.attachRenderer(render); + + document.getElementById('file').addEventListener('click', e => { + document.body.removeChild(document.getElementById('loaded')); + }); + + document.getElementById('file').addEventListener('change', e => { + const reader = new FileReader(); + const thisFileInput = e.target; + reader.onload = () => { + vm.start(); + vm.loadProject(reader.result) + .then(() => { + // we add a `#loaded` div to our document, the integration suite + // waits for that element to show up to assume the vm is ready + // to play! + const div = document.createElement('div'); + div.id='loaded'; + document.body.appendChild(div); + vm.greenFlag(); + setTimeout(() => { + renderCpu(); + }, 1000); + }); + }; + reader.readAsArrayBuffer(thisFileInput.files[0]); + }); + + const cpuCanvas = document.getElementById('cpu'); + const cpuCtx = cpuCanvas.getContext('2d'); + const cpuImageData = cpuCtx.getImageData(0, 0, cpuCanvas.width, cpuCanvas.height); + function renderCpu() { + cpuImageData.data.fill(255); + const drawBits = render._drawList.map(id => { + const drawable = render._allDrawables[id]; + if (!(drawable._visible && drawable.skin)) { + return; + } + drawable.updateMatrix(); + drawable.skin.updateSilhouette(); + return { id, drawable }; + }).reverse().filter(Boolean); + const color = new Uint8ClampedArray(3); + for (let x = -239; x <= 240; x++) { + for (let y = -180; y< 180; y++) { + render.constructor.sampleColor3b([x, y], drawBits, color); + const offset = (((179-y) * 480) + 239 + x) * 4 + cpuImageData.data.set(color, offset); + } + } + cpuCtx.putImageData(cpuImageData, 0, 0); + + const merge = document.getElementById('merge'); + const ctx = merge.getContext('2d'); + ctx.drawImage(gpuCanvas, 0, 0); + const gpuImageData = ctx.getImageData(0, 0, 480, 360); + for (let x=0; x<gpuImageData.data.length; x++) { + gpuImageData.data[x] = 255 - Math.abs(gpuImageData.data[x] - cpuImageData.data[x]); + } + + ctx.putImageData(gpuImageData, 0, 0); + } + </script> +</body> diff --git a/test/integration/index.html b/test/integration/index.html index 43c603c9..e3d8dd83 100644 --- a/test/integration/index.html +++ b/test/integration/index.html @@ -11,6 +11,7 @@ <script> // These variables are going to be available in the "window global" intentionally. // Allows you easy access to debug with `vm.greenFlag()` etc. + window.devicePixelRatio = 1; var canvas = document.getElementById('test'); var render = new ScratchRender(canvas); diff --git a/test/integration/pick-tests.js b/test/integration/pick-tests.js index a34c0b99..97890ac5 100644 --- a/test/integration/pick-tests.js +++ b/test/integration/pick-tests.js @@ -26,7 +26,13 @@ const runFile = (file, script) => vm.greenFlag(); const sendResults = []; - const idToTargetName = id => vm.runtime.targets.find(target => target.drawableID === id).sprite.name; + const idToTargetName = id => { + const target = vm.runtime.targets.find(tar => tar.drawableID === id); + if (!target) { + return `[Unknown drawableID: ${id}]`; + } + return target.sprite.name; + }; const sprite = vm.runtime.targets.find(target => target.sprite.name === 'Sprite1'); sendResults.push(['center', idToTargetName(render.pick(240, 180))]);