From be6d2dc4e4d739ef6dd354578105d7cf20e5fab2 Mon Sep 17 00:00:00 2001 From: Tim Mickel Date: Mon, 24 Oct 2016 13:19:20 -0400 Subject: [PATCH] Bounding-box optimizations for touching color, touching drawables (#55) * Add Rectangle utility and use it in Drawable.getBounds * Add `getAABB`, `getFastBounds`. * Add width and height getters to Rectangle * Add rectangle clamp * Optimized isTouchingColor * Optimized isTouchingDrawables * Rectangle.ceil -> Rectangle.snapToInt * Refactor to common _touchingQueryCandidates * Split helper into two --- playground/index.html | 4 +- src/Drawable.js | 64 +++++++++++++------- src/Rectangle.js | 138 ++++++++++++++++++++++++++++++++++++++++++ src/RenderWebGL.js | 130 +++++++++++++++++++++++++++++---------- 4 files changed, 280 insertions(+), 56 deletions(-) create mode 100644 src/Rectangle.js diff --git a/playground/index.html b/playground/index.html index 73407d4..19d0bac 100644 --- a/playground/index.html +++ b/playground/index.html @@ -5,7 +5,6 @@ Scratch WebGL rendering demo @@ -129,7 +128,8 @@ function drawStep() { renderer.draw(); - renderer.getBounds(drawableID2); + //renderer.getBounds(drawableID2); + renderer.isTouchingColor(drawableID2, [255,255,255]); requestAnimationFrame(drawStep); } drawStep(); diff --git a/src/Drawable.js b/src/Drawable.js index 5d07567..b32daf1 100644 --- a/src/Drawable.js +++ b/src/Drawable.js @@ -2,6 +2,7 @@ var twgl = require('twgl.js'); var svgToImage = require('svg-to-image'); var xhr = require('xhr'); +var Rectangle = require('./Rectangle'); var ShaderManager = require('./ShaderManager'); class Drawable { @@ -461,7 +462,7 @@ Drawable.prototype.setConvexHullPoints = function (points) { * This function applies the transform matrix to the known convex hull, * and then finds the minimum box along the axes. * Before calling this, ensure the renderer has updated convex hull points. - * @return {Object} Bounds for a tight box around the Drawable. + * @return {!Rectangle} Bounds for a tight box around the Drawable. */ Drawable.prototype.getBounds = function () { if (this.needsConvexHullPoints()) { @@ -488,31 +489,48 @@ Drawable.prototype.getBounds = function () { transformedHullPoints.push(glPoint); } // Search through transformed points to generate box on axes. - let bounds = { - left: Infinity, - right: -Infinity, - top: -Infinity, - bottom: Infinity - }; - for (let i = 0; i < transformedHullPoints.length; i++) { - let x = transformedHullPoints[i][0]; - let y = transformedHullPoints[i][1]; - if (x < bounds.left) { - bounds.left = x; - } - if (x > bounds.right) { - bounds.right = x; - } - if (y > bounds.top) { - bounds.top = y; - } - if (y < bounds.bottom) { - bounds.bottom = y; - } - } + let bounds = new Rectangle(); + bounds.initFromPointsAABB(transformedHullPoints); return bounds; }; +/** + * Get the rough axis-aligned bounding box for the Drawable. + * Calculated by transforming the skin's bounds. + * Note that this is less precise than the box returned by `getBounds`, + * which is tightly snapped to account for a Drawable's transparent regions. + * `getAABB` returns a much less accurate bounding box, but will be much + * faster to calculate so may be desired for quick checks/optimizations. + * @return {!Rectangle} Rough axis-aligned bounding box for Drawable. + */ +Drawable.prototype.getAABB = function () { + if (this._transformDirty) { + this._calculateTransform(); + } + const tm = this._uniforms.u_modelMatrix; + const bounds = new Rectangle(); + bounds.initFromPointsAABB([ + twgl.m4.transformPoint(tm, [-0.5, -0.5, 0]), + twgl.m4.transformPoint(tm, [0.5, -0.5, 0]), + twgl.m4.transformPoint(tm, [-0.5, 0.5, 0]), + twgl.m4.transformPoint(tm, [0.5, 0.5, 0]) + ]); + return bounds; +}; + +/** + * Return the best Drawable bounds possible without performing graphics queries. + * I.e., returns the tight bounding box when the convex hull points are already + * known, but otherwise return the rough AABB of the Drawable. + * @return {!Rectangle} Bounds for the Drawable. + */ +Drawable.prototype.getFastBounds = function () { + if (!this.needsConvexHullPoints()) { + return this.getBounds(); + } + return this.getAABB(); +}; + /** * Calculate a color to represent the given ID number. At least one component of * the resulting color will be non-zero if the ID is not Drawable.NONE. diff --git a/src/Rectangle.js b/src/Rectangle.js new file mode 100644 index 0000000..9abcc6b --- /dev/null +++ b/src/Rectangle.js @@ -0,0 +1,138 @@ +/** + * @fileoverview + * A utility for creating and comparing axis-aligned rectangles. + */ + +class Rectangle { + /** + * Rectangles are always initialized to the "largest possible rectangle"; + * use one of the init* methods below to set up a particular rectangle. + * @constructor + */ + constructor () { + this.left = -Infinity; + this.right = Infinity; + this.bottom = -Infinity; + this.top = Infinity; + } + + /** + * Initialize a Rectangle from given Scratch-coordinate bounds. + * @param {number} left Left bound of the rectangle. + * @param {number} right Right bound of the rectangle. + * @param {number} bottom Bottom bound of the rectangle. + * @param {number} top Top bound of the rectangle. + */ + initFromBounds (left, right, bottom, top) { + this.left = left; + this.right = right; + this.bottom = bottom; + this.top = top; + } + + /** + * Initialize a Rectangle to the minimum AABB around a set of points. + * @param {Array.>} points Array of [x, y] points. + */ + initFromPointsAABB (points) { + this.left = Infinity; + this.right = -Infinity; + this.top = -Infinity; + this.bottom = Infinity; + for (let i = 0; i < points.length; i++) { + let x = points[i][0]; + let y = points[i][1]; + if (x < this.left) { + this.left = x; + } + if (x > this.right) { + this.right = x; + } + if (y > this.top) { + this.top = y; + } + if (y < this.bottom) { + this.bottom = y; + } + } + } + + /** + * Determine if this Rectangle intersects some other. + * Note that this is a comparison assuming the Rectangle was + * initialized with Scratch-space bounds or points. + * @param {!Rectangle} other Rectangle to check if intersecting. + * @return {Boolean} True if this Rectangle intersects other. + */ + intersects (other) { + return ( + this.left <= other.right && + other.left <= this.right && + this.top >= other.bottom && + other.top >= this.bottom + ); + } + + /** + * Determine if this Rectangle fully contains some other. + * Note that this is a comparison assuming the Rectangle was + * initialized with Scratch-space bounds or points. + * @param {!Rectangle} other Rectangle to check if fully contained. + * @return {Boolean} True if this Rectangle fully contains other. + */ + contains (other) { + return ( + other.left > this.left && + other.right < this.right && + other.top < this.top && + other.bottom > this.bottom + ); + } + + /** + * Clamp a Rectangle to bounds. + * @param {number} left Left clamp. + * @param {number} right Right clamp. + * @param {number} bottom Bottom clamp. + * @param {number} top Top clamp. + */ + clamp (left, right, bottom, top) { + this.left = Math.max(this.left, left); + this.right = Math.min(this.right, right); + this.bottom = Math.max(this.bottom, bottom); + this.top = Math.min(this.top, top); + // Ensure rectangle coordinates in order. + this.left = Math.min(this.left, this.right); + this.right = Math.max(this.right, this.left); + this.bottom = Math.min(this.bottom, this.top); + this.top = Math.max(this.top, this.bottom); + } + + /** + * Push out the Rectangle to integer bounds. + */ + snapToInt() { + this.left = Math.floor(this.left); + this.right = Math.ceil(this.right); + this.bottom = Math.floor(this.bottom); + this.top = Math.ceil(this.top); + } + + /** + * Width of the Rectangle. + * @return {number} Width of rectangle. + */ + get width () { + return Math.abs(this.left - this.right); + } + + /** + * Height of the Rectangle. + * @return {number} Height of rectangle. + */ + get height () { + return Math.abs(this.top - this.bottom); + } +} + +module.exports = Rectangle; diff --git a/src/RenderWebGL.js b/src/RenderWebGL.js index 865ea89..ba70e76 100644 --- a/src/RenderWebGL.js +++ b/src/RenderWebGL.js @@ -243,16 +243,25 @@ RenderWebGL.prototype.getSkinSize = function (drawableID) { * @returns {Boolean} True iff the Drawable is touching the color. */ RenderWebGL.prototype.isTouchingColor = function(drawableID, color3b, mask3b) { - - var gl = this._gl; - + const gl = this._gl; twgl.bindFramebufferInfo(gl, this._queryBufferInfo); - // TODO: restrict to only the area overlapped by the target Drawable - // - limit size of viewport to the AABB around the target Drawable - // - draw only the Drawables which could overlap the target Drawable - // - read only the pixels in the AABB around the target Drawable - gl.viewport(0, 0, this._nativeSize[0], this._nativeSize[1]); + let bounds = this._touchingBounds(drawableID); + if (!bounds) { + return; + } + let candidateIDs = this._filterCandidatesTouching( + drawableID, this._drawables, bounds); + if (!candidateIDs) { + return; + } + + + // Limit size of viewport to the bounds around the target Drawable, + // and create the projection matrix for the draw. + gl.viewport(0, 0, bounds.width, bounds.height); + const projection = twgl.m4.ortho( + bounds.left, bounds.right, bounds.bottom, bounds.top, -1, 1); gl.clearColor.apply(gl, this._backgroundColor); gl.clear(gl.COLOR_BUFFER_BIT | gl.STENCIL_BUFFER_BIT); @@ -275,7 +284,7 @@ RenderWebGL.prototype.isTouchingColor = function(drawableID, color3b, mask3b) { mask3b ? ShaderManager.DRAW_MODE.colorMask : ShaderManager.DRAW_MODE.silhouette, - this._projection, + projection, undefined, extraUniforms); @@ -283,10 +292,8 @@ RenderWebGL.prototype.isTouchingColor = function(drawableID, color3b, mask3b) { gl.stencilOp(gl.KEEP, gl.KEEP, gl.KEEP); gl.colorMask(true, true, true, true); - // TODO: only draw items which could possibly overlap target Drawable - // It might work to use the filter function for that this._drawThese( - this._drawables, ShaderManager.DRAW_MODE.default, this._projection, + candidateIDs, ShaderManager.DRAW_MODE.default, projection, function (testID) { return testID != drawableID; }); @@ -296,17 +303,17 @@ RenderWebGL.prototype.isTouchingColor = function(drawableID, color3b, mask3b) { gl.disable(gl.STENCIL_TEST); } - var pixels = new Buffer(this._nativeSize[0] * this._nativeSize[1] * 4); + var pixels = new Buffer(bounds.width * bounds.height * 4); gl.readPixels( - 0, 0, this._nativeSize[0], this._nativeSize[1], + 0, 0, bounds.width, bounds.height, gl.RGBA, gl.UNSIGNED_BYTE, pixels); if (this._debugCanvas) { - this._debugCanvas.width = this._nativeSize[0]; - this._debugCanvas.height = this._nativeSize[1]; + this._debugCanvas.width = bounds.width; + this._debugCanvas.height = bounds.height; var context = this._debugCanvas.getContext('2d'); var imageData = context.getImageData( - 0, 0, this._nativeSize[0], this._nativeSize[1]); + 0, 0, bounds.width, bounds.height); for (var i = 0, bytes = pixels.length; i < bytes; ++i) { imageData.data[i] = pixels[i]; } @@ -341,11 +348,21 @@ RenderWebGL.prototype.isTouchingDrawables = function(drawableID, candidateIDs) { twgl.bindFramebufferInfo(gl, this._queryBufferInfo); - // TODO: restrict to only the area overlapped by the target Drawable - // - limit size of viewport to the AABB around the target Drawable - // - draw only the Drawables which could overlap the target Drawable - // - read only the pixels in the AABB around the target Drawable - gl.viewport(0, 0, this._nativeSize[0], this._nativeSize[1]); + let bounds = this._touchingBounds(drawableID); + if (!bounds) { + return; + } + candidateIDs = this._filterCandidatesTouching( + drawableID, candidateIDs, bounds); + if (!candidateIDs) { + return; + } + + // Limit size of viewport to the bounds around the target Drawable, + // and create the projection matrix for the draw. + gl.viewport(0, 0, bounds.width, bounds.height); + const projection = twgl.m4.ortho( + bounds.left, bounds.right, bounds.bottom, bounds.top, -1, 1); const noneColor = Drawable.color4fFromID(Drawable.NONE); gl.clearColor.apply(gl, noneColor); @@ -357,34 +374,35 @@ RenderWebGL.prototype.isTouchingDrawables = function(drawableID, candidateIDs) { gl.stencilOp(gl.KEEP, gl.KEEP, gl.REPLACE); gl.colorMask(false, false, false, false); this._drawThese( - [drawableID], ShaderManager.DRAW_MODE.silhouette, this._projection + [drawableID], ShaderManager.DRAW_MODE.silhouette, projection ); gl.stencilFunc(gl.EQUAL, 1, 1); gl.stencilOp(gl.KEEP, gl.KEEP, gl.KEEP); gl.colorMask(true, true, true, true); - // TODO: only draw items which could possibly overlap target Drawable - // It might work to use the filter function for that this._drawThese( - candidateIDs, ShaderManager.DRAW_MODE.silhouette, this._projection + candidateIDs, ShaderManager.DRAW_MODE.silhouette, projection, + function (testID) { + return testID != drawableID; + } ); } finally { gl.colorMask(true, true, true, true); gl.disable(gl.STENCIL_TEST); } - let pixels = new Buffer(this._nativeSize[0] * this._nativeSize[1] * 4); + let pixels = new Buffer(bounds.width * bounds.height * 4); gl.readPixels( - 0, 0, this._nativeSize[0], this._nativeSize[1], + 0, 0, bounds.width, bounds.height, gl.RGBA, gl.UNSIGNED_BYTE, pixels); if (this._debugCanvas) { - this._debugCanvas.width = this._nativeSize[0]; - this._debugCanvas.height = this._nativeSize[1]; + this._debugCanvas.width = bounds.width; + this._debugCanvas.height = bounds.height; const context = this._debugCanvas.getContext('2d'); let imageData = context.getImageData( - 0, 0, this._nativeSize[0], this._nativeSize[1]); + 0, 0, bounds.width, bounds.height); for (let i = 0, bytes = pixels.length; i < bytes; ++i) { imageData.data[i] = pixels[i]; } @@ -500,6 +518,56 @@ RenderWebGL.prototype.pick = function ( return hit | 0; }; +/** + * Get the candidate bounding box for a touching query. + * @param {int} drawableID ID for drawable of query. + * @return {?Rectangle} Rectangle bounds for touching query, or null. + */ +RenderWebGL.prototype._touchingBounds = function (drawableID) { + const drawable = Drawable.getDrawableByID(drawableID); + const bounds = drawable.getFastBounds(); + + // Limit queries to the stage size. + bounds.clamp(this._xLeft, this._xRight, this._yBottom, this._yTop); + + // Use integer coordinates for queries - weird things happen + // when you provide float width/heights to gl.viewport and projection. + bounds.snapToInt(); + + if (bounds.width == 0 || bounds.height == 0) { + // No space to query. + return null; + } + return bounds; +}; + +/** + * Filter a list of candidates for a touching query into only those that + * could possibly intersect the given bounds. + * @param {int} drawableID ID for drawable of query. + * @param {Array.} candidateIDs Candidates for touching query. + * @param {Rectangle} Bounds to limit candidates to. + * @return {?Array.} Filtered candidateIDs, or null if none. + */ +RenderWebGL.prototype._filterCandidatesTouching = function ( + drawableID, candidateIDs, bounds) { + // Filter candidates by rough bounding box intersection. + // Do this before _drawThese, so we can prevent any GL operations + // and readback by returning early. + candidateIDs = candidateIDs.filter(function (testID) { + if (testID == drawableID) return false; + // Only draw items which could possibly overlap target Drawable. + let candidate = Drawable.getDrawableByID(testID); + let candidateBounds = candidate.getFastBounds(); + return bounds.intersects(candidateBounds); + }); + if (candidateIDs.length == 0) { + // No possible intersections based on bounding boxes. + return null; + } + return candidateIDs; +}; + /** * Update the position, direction, scale, or effect properties of this Drawable. * @param {int} drawableID The ID of the Drawable to update.