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
This commit is contained in:
Tim Mickel 2016-10-24 13:19:20 -04:00 committed by GitHub
parent 2cffa7b643
commit be6d2dc4e4
4 changed files with 280 additions and 56 deletions

View File

@ -5,7 +5,6 @@
<title>Scratch WebGL rendering demo</title>
<style>
#scratch-stage { width: 480px; }
#debug-canvas { width: 480px; }
</style>
</head>
<body style="background: lightsteelblue">
@ -129,7 +128,8 @@
function drawStep() {
renderer.draw();
renderer.getBounds(drawableID2);
//renderer.getBounds(drawableID2);
renderer.isTouchingColor(drawableID2, [255,255,255]);
requestAnimationFrame(drawStep);
}
drawStep();

View File

@ -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.

138
src/Rectangle.js Normal file
View File

@ -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.<Array.<number>>} 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;

View File

@ -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.<int>} candidateIDs Candidates for touching query.
* @param {Rectangle} Bounds to limit candidates to.
* @return {?Array.<int>} 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.