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:
parent
2cffa7b643
commit
be6d2dc4e4
@ -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();
|
||||
|
@ -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
138
src/Rectangle.js
Normal 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;
|
@ -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.
|
||||
|
Loading…
x
Reference in New Issue
Block a user