scratch-render
Version:
WebGL Renderer for Scratch 3.0
1,156 lines (992 loc) • 44.7 kB
JavaScript
const EventEmitter = require('events');
const hull = require('hull.js');
const twgl = require('twgl.js');
const xhr = require('xhr');
const BitmapSkin = require('./BitmapSkin');
const Drawable = require('./Drawable');
const PenSkin = require('./PenSkin');
const RenderConstants = require('./RenderConstants');
const ShaderManager = require('./ShaderManager');
const SVGSkin = require('./SVGSkin');
/**
* @callback RenderWebGL#idFilterFunc
* @param {int} drawableID The ID to filter.
* @return {bool} True if the ID passes the filter, otherwise false.
*/
/**
* Maximum touch size for a picking check.
* @todo Figure out a reasonable max size. Maybe this should be configurable?
* @type {Array<int>}
* @memberof RenderWebGL
*/
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 {int}
* @memberof RenderWebGL
*/
const TOLERANCE_TOUCHING_COLOR = 2;
/**
* Sprite Fencing - The number of pixels a sprite is required to leave remaining
* onscreen around the edge of the staging area.
* @type {number}
*/
const FENCE_WIDTH = 15;
class RenderWebGL extends EventEmitter {
/**
* Create a renderer for drawing Scratch sprites to a canvas using WebGL.
* Coordinates will default to Scratch 2.0 values if unspecified.
* The stage's "native" size will be calculated from the these coordinates.
* For example, the defaults result in a native size of 480x360.
* Queries such as "touching color?" will always execute at the native size.
* @see RenderWebGL#setStageSize
* @see RenderWebGL#resize
* @param {canvas} canvas The canvas to draw onto.
* @param {int} [xLeft=-240] The x-coordinate of the left edge.
* @param {int} [xRight=240] The x-coordinate of the right edge.
* @param {int} [yBottom=-180] The y-coordinate of the bottom edge.
* @param {int} [yTop=180] The y-coordinate of the top edge.
* @constructor
* @listens RenderWebGL#event:NativeSizeChanged
*/
constructor (canvas, xLeft, xRight, yBottom, yTop) {
super();
/** @type {Drawable[]} */
this._allDrawables = [];
/** @type {Skin[]} */
this._allSkins = [];
/** @type {Array<int>} */
this._drawList = [];
/** @type {WebGLRenderingContext} */
const gl = this._gl = twgl.getWebGLContext(canvas, {alpha: false, stencil: true});
/** @type {int} */
this._nextDrawableId = RenderConstants.ID_NONE + 1;
/** @type {int} */
this._nextSkinId = RenderConstants.ID_NONE + 1;
/** @type {module:twgl/m4.Mat4} */
this._projection = twgl.m4.identity();
/** @type {Object.<string,int>} */
this._skinUrlMap = {};
/** @type {ShaderManager} */
this._shaderManager = new ShaderManager(gl);
/** @type {HTMLCanvasElement} */
this._tempCanvas = document.createElement('canvas');
this._createGeometry();
this.on(RenderConstants.Events.NativeSizeChanged, this.onNativeSizeChanged);
this.setBackgroundColor(1, 1, 1);
this.setStageSize(xLeft || -240, xRight || 240, yBottom || -180, yTop || 180);
this.resize(this._nativeSize[0], this._nativeSize[1]);
gl.disable(gl.DEPTH_TEST);
/** @todo disable when no partial transparency? */
gl.enable(gl.BLEND);
gl.blendFuncSeparate(gl.ONE, gl.ONE_MINUS_SRC_ALPHA, gl.ZERO, gl.ONE);
}
/**
* @returns {WebGLRenderingContext} the WebGL rendering context associated with this renderer.
*/
get gl () {
return this._gl;
}
/**
* Set the physical size of the stage in device-independent pixels.
* This will be multiplied by the device's pixel ratio on high-DPI displays.
* @param {int} pixelsWide The desired width in device-independent pixels.
* @param {int} pixelsTall The desired height in device-independent pixels.
*/
resize (pixelsWide, pixelsTall) {
const pixelRatio = window.devicePixelRatio || 1;
this._gl.canvas.width = pixelsWide * pixelRatio;
this._gl.canvas.height = pixelsTall * pixelRatio;
}
/**
* Set the background color for the stage. The stage will be cleared with this
* color each frame.
* @param {number} red The red component for the background.
* @param {number} green The green component for the background.
* @param {number} blue The blue component for the background.
*/
setBackgroundColor (red, green, blue) {
this._backgroundColor = [red, green, blue, 1];
}
/**
* Tell the renderer to draw various debug information to the provided canvas
* during certain operations.
* @param {canvas} canvas The canvas to use for debug output.
*/
setDebugCanvas (canvas) {
this._debugCanvas = canvas;
}
/**
* Set logical size of the stage in Scratch units.
* @param {int} xLeft The left edge's x-coordinate. Scratch 2 uses -240.
* @param {int} xRight The right edge's x-coordinate. Scratch 2 uses 240.
* @param {int} yBottom The bottom edge's y-coordinate. Scratch 2 uses -180.
* @param {int} yTop The top edge's y-coordinate. Scratch 2 uses 180.
*/
setStageSize (xLeft, xRight, yBottom, yTop) {
this._xLeft = xLeft;
this._xRight = xRight;
this._yBottom = yBottom;
this._yTop = yTop;
// swap yBottom & yTop to fit Scratch convention of +y=up
this._projection = twgl.m4.ortho(xLeft, xRight, yBottom, yTop, -1, 1);
this._setNativeSize(Math.abs(xRight - xLeft), Math.abs(yBottom - yTop));
}
/**
* @return {Array<int>} the "native" size of the stage, which is used for pen, query renders, etc.
*/
getNativeSize () {
return [this._nativeSize[0], this._nativeSize[1]];
}
/**
* Set the "native" size of the stage, which is used for pen, query renders, etc.
* @param {int} width - the new width to set.
* @param {int} height - the new height to set.
* @private
* @fires RenderWebGL#event:NativeSizeChanged
*/
_setNativeSize (width, height) {
this._nativeSize = [width, height];
this.emit(RenderConstants.Events.NativeSizeChanged, {newSize: this._nativeSize});
}
/**
* Create a skin by loading a bitmap or vector image from a URL, or reuse an existing skin created this way.
* WARNING: This method is deprecated and will be removed in the near future.
* Use `createBitmapSkin` or `createSVGSkin` instead.
* @param {!string} skinUrl The URL of the skin.
* @param {!int} [costumeResolution] Optional: resolution for the skin. Ignored unless creating a new Bitmap skin.
* @param {?Array<number>} rotationCenter Optional: rotation center of the skin. If not supplied, the center of the
* skin will be used.
* @returns {!int} The ID of the Skin.
* @deprecated
*/
createSkinFromURL (skinUrl, costumeResolution, rotationCenter) {
if (this._skinUrlMap.hasOwnProperty(skinUrl)) {
const existingId = this._skinUrlMap[skinUrl];
// Make sure the "existing" skin hasn't been destroyed
if (this._allSkins[existingId]) {
return existingId;
}
}
const skinId = this._nextSkinId++;
this._skinUrlMap[skinUrl] = skinId;
let newSkin;
let isVector;
const ext = skinUrl.substring(skinUrl.lastIndexOf('.') + 1);
switch (ext) {
case 'svg':
case 'svg/get/':
case 'svgz':
case 'svgz/get/':
isVector = true;
break;
default:
isVector = false;
break;
}
if (isVector) {
newSkin = new SVGSkin(skinId, this);
xhr.get({
useXDR: true,
url: skinUrl
}, (err, response, body) => {
if (!err) {
newSkin.setSVG(body, rotationCenter);
}
});
} else {
newSkin = new BitmapSkin(skinId, this);
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
newSkin.setBitmap(img, costumeResolution, rotationCenter);
};
img.src = skinUrl;
}
this._allSkins[skinId] = newSkin;
return skinId;
}
/**
* Create a new bitmap skin from a snapshot of the provided bitmap data.
* @param {ImageData|HTMLImageElement|HTMLCanvasElement|HTMLVideoElement} bitmapData - new contents for this skin.
* @param {!int} [costumeResolution=1] - The resolution to use for this bitmap.
* @param {?Array<number>} rotationCenter Optional: rotation center of the skin. If not supplied, the center of the
* skin will be used
* @returns {!int} the ID for the new skin.
*/
createBitmapSkin (bitmapData, costumeResolution, rotationCenter) {
const skinId = this._nextSkinId++;
const newSkin = new BitmapSkin(skinId, this);
newSkin.setBitmap(bitmapData, costumeResolution, rotationCenter);
this._allSkins[skinId] = newSkin;
return skinId;
}
/**
* Create a new SVG skin.
* @param {!string} svgData - new SVG to use.
* @param {?Array<number>} rotationCenter Optional: rotation center of the skin. If not supplied, the center of the
* skin will be used
* @returns {!int} the ID for the new skin.
*/
createSVGSkin (svgData, rotationCenter) {
const skinId = this._nextSkinId++;
const newSkin = new SVGSkin(skinId, this);
newSkin.setSVG(svgData, rotationCenter);
this._allSkins[skinId] = newSkin;
return skinId;
}
/**
* Create a new PenSkin - a skin which implements a Scratch pen layer.
* @returns {!int} the ID for the new skin.
*/
createPenSkin () {
const skinId = this._nextSkinId++;
const newSkin = new PenSkin(skinId, this);
this._allSkins[skinId] = newSkin;
return skinId;
}
/**
* Destroy an existing skin. Do not use the skin or its ID after calling this.
* @param {!int} skinId - The ID of the skin to destroy.
*/
destroySkin (skinId) {
const oldSkin = this._allSkins[skinId];
oldSkin.dispose();
delete this._allSkins[skinId];
}
/**
* Create a new Drawable and add it to the scene.
* @returns {int} The ID of the new Drawable.
*/
createDrawable () {
const drawableID = this._nextDrawableId++;
const drawable = new Drawable(drawableID, this);
this._allDrawables[drawableID] = drawable;
this._drawList.push(drawableID);
const defaultSkinId = this.createSkinFromURL(RenderConstants.DEFAULT_SKIN);
drawable.skin = this._allSkins[defaultSkinId];
return drawableID;
}
/**
* Destroy a Drawable, removing it from the scene.
* @param {int} drawableID The ID of the Drawable to remove.
*/
destroyDrawable (drawableID) {
const drawable = this._allDrawables[drawableID];
drawable.dispose();
delete this._allDrawables[drawableID];
let index;
while ((index = this._drawList.indexOf(drawableID)) >= 0) {
this._drawList.splice(index, 1);
}
}
/**
* Set a drawable's order in the drawable list (effectively, z/layer).
* Can be used to move drawables to absolute positions in the list,
* or relative to their current positions.
* "go back N layers": setDrawableOrder(id, -N, true, 1); (assuming stage at 0).
* "go to back": setDrawableOrder(id, 1); (assuming stage at 0).
* "go to front": setDrawableOrder(id, Infinity);
* @param {int} drawableID ID of Drawable to reorder.
* @param {number} order New absolute order or relative order adjusment.
* @param {boolean=} optIsRelative If set, `order` refers to a relative change.
* @param {number=} optMin If set, order constrained to be at least `optMin`.
* @return {?number} New order if changed, or null.
*/
setDrawableOrder (drawableID, order, optIsRelative, optMin) {
const oldIndex = this._drawList.indexOf(drawableID);
if (oldIndex >= 0) {
// Remove drawable from the list.
const drawable = this._drawList.splice(oldIndex, 1)[0];
// Determine new index.
let newIndex = order;
if (optIsRelative) {
newIndex += oldIndex;
}
if (optMin) {
newIndex = Math.max(newIndex, optMin);
}
newIndex = Math.max(newIndex, 0);
// Insert at new index.
this._drawList.splice(newIndex, 0, drawable);
return this._drawList.indexOf(drawable);
}
return null;
}
/**
* Draw all current drawables and present the frame on the canvas.
*/
draw () {
const gl = this._gl;
twgl.bindFramebufferInfo(gl, null);
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
gl.clearColor.apply(gl, this._backgroundColor);
gl.clear(gl.COLOR_BUFFER_BIT);
this._drawThese(this._drawList, ShaderManager.DRAW_MODE.default, this._projection);
}
/**
* Get the precise bounds for a Drawable.
* @param {int} drawableID ID of Drawable to get bounds for.
* @return {object} Bounds for a tight box around the Drawable.
*/
getBounds (drawableID) {
const drawable = this._allDrawables[drawableID];
// Tell the Drawable about its updated convex hull, if necessary.
if (drawable.needsConvexHullPoints()) {
const points = this._getConvexHullPointsForDrawable(drawableID);
drawable.setConvexHullPoints(points);
}
const bounds = drawable.getBounds();
// In debug mode, draw the bounds.
if (this._debugCanvas) {
const gl = this._gl;
this._debugCanvas.width = gl.canvas.width;
this._debugCanvas.height = gl.canvas.height;
const context = this._debugCanvas.getContext('2d');
context.drawImage(gl.canvas, 0, 0);
context.strokeStyle = '#FF0000';
const pr = window.devicePixelRatio;
context.strokeRect(
pr * (bounds.left + (this._nativeSize[0] / 2)),
pr * (-bounds.top + (this._nativeSize[1] / 2)),
pr * (bounds.right - bounds.left),
pr * (-bounds.bottom + bounds.top)
);
}
return bounds;
}
/**
* Get the current skin (costume) size of a Drawable.
* @param {int} drawableID The ID of the Drawable to measure.
* @return {Array<number>} Skin size, width and height.
*/
getSkinSize (drawableID) {
const drawable = this._allDrawables[drawableID];
return drawable.skin.size;
}
/**
* Check if a particular Drawable is touching a particular color.
* @param {int} drawableID The ID of the Drawable to check.
* @param {Array<int>} color3b Test if the Drawable is touching this color.
* @param {Array<int>} [mask3b] Optionally mask the check to this part of Drawable.
* @returns {boolean} True iff the Drawable is touching the color.
*/
isTouchingColor (drawableID, color3b, mask3b) {
const gl = this._gl;
twgl.bindFramebufferInfo(gl, this._queryBufferInfo);
const bounds = this._touchingBounds(drawableID);
if (!bounds) {
return;
}
const candidateIDs = this._filterCandidatesTouching(drawableID, this._drawList, 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.top, bounds.bottom, -1, 1);
gl.clearColor.apply(gl, this._backgroundColor);
gl.clear(gl.COLOR_BUFFER_BIT | gl.STENCIL_BUFFER_BIT);
let extraUniforms;
if (mask3b) {
extraUniforms = {
u_colorMask: [mask3b[0] / 255, mask3b[1] / 255, mask3b[2] / 255],
u_colorMaskTolerance: TOLERANCE_TOUCHING_COLOR / 255
};
}
try {
gl.enable(gl.STENCIL_TEST);
gl.stencilFunc(gl.ALWAYS, 1, 1);
gl.stencilOp(gl.KEEP, gl.KEEP, gl.REPLACE);
gl.colorMask(false, false, false, false);
this._drawThese(
[drawableID],
mask3b ?
ShaderManager.DRAW_MODE.colorMask :
ShaderManager.DRAW_MODE.silhouette,
projection,
{extraUniforms});
gl.stencilFunc(gl.EQUAL, 1, 1);
gl.stencilOp(gl.KEEP, gl.KEEP, gl.KEEP);
gl.colorMask(true, true, true, true);
this._drawThese(candidateIDs, ShaderManager.DRAW_MODE.default, projection,
{idFilterFunc: testID => testID !== drawableID}
);
} finally {
gl.colorMask(true, true, true, true);
gl.disable(gl.STENCIL_TEST);
}
const pixels = new Uint8Array(bounds.width * bounds.height * 4);
gl.readPixels(0, 0, bounds.width, bounds.height, 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);
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 &&
pixelDistanceG <= TOLERANCE_TOUCHING_COLOR &&
pixelDistanceB <= TOLERANCE_TOUCHING_COLOR) {
return true;
}
}
return false;
}
/**
* Check if a particular Drawable is touching any in a set of Drawables.
* @param {int} drawableID The ID of the Drawable to check.
* @param {Array<int>} candidateIDs The Drawable IDs to check, otherwise all.
* @returns {boolean} True iff the Drawable is touching one of candidateIDs.
*/
isTouchingDrawables (drawableID, candidateIDs) {
candidateIDs = candidateIDs || this._drawList;
const gl = this._gl;
twgl.bindFramebufferInfo(gl, this._queryBufferInfo);
const 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.top, bounds.bottom, -1, 1);
const noneColor = Drawable.color4fFromID(RenderConstants.ID_NONE);
gl.clearColor.apply(gl, noneColor);
gl.clear(gl.COLOR_BUFFER_BIT | gl.STENCIL_BUFFER_BIT);
try {
gl.enable(gl.STENCIL_TEST);
gl.stencilFunc(gl.ALWAYS, 1, 1);
gl.stencilOp(gl.KEEP, gl.KEEP, gl.REPLACE);
gl.colorMask(false, false, false, false);
this._drawThese([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);
this._drawThese(candidateIDs, ShaderManager.DRAW_MODE.silhouette, projection,
{idFilterFunc: testID => testID !== drawableID}
);
} finally {
gl.colorMask(true, true, true, true);
gl.disable(gl.STENCIL_TEST);
}
const pixels = new Uint8Array(bounds.width * bounds.height * 4);
gl.readPixels(0, 0, bounds.width, bounds.height, 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);
imageData.data.set(pixels);
context.putImageData(imageData, 0, 0);
}
for (let pixelBase = 0; pixelBase < pixels.length; pixelBase += 4) {
const pixelID = Drawable.color3bToID(
pixels[pixelBase],
pixels[pixelBase + 1],
pixels[pixelBase + 2]);
if (pixelID > RenderConstants.ID_NONE) {
return true;
}
}
return false;
}
/**
* Detect which sprite, if any, is at the given location.
* @param {int} centerX The client x coordinate of the picking location.
* @param {int} centerY The client y coordinate of the picking location.
* @param {int} touchWidth The client width of the touch event (optional).
* @param {int} touchHeight The client height of the touch event (optional).
* @param {Array<int>} candidateIDs The Drawable IDs to pick from, otherwise all.
* @returns {int} The ID of the topmost Drawable under the picking location, or
* RenderConstants.ID_NONE if there is no Drawable at that location.
*/
pick (centerX, centerY, touchWidth, touchHeight, candidateIDs) {
const gl = this._gl;
touchWidth = touchWidth || 1;
touchHeight = touchHeight || 1;
candidateIDs = candidateIDs || this._drawList;
const clientToGLX = gl.canvas.width / gl.canvas.clientWidth;
const clientToGLY = gl.canvas.height / gl.canvas.clientHeight;
centerX *= clientToGLX;
centerY *= clientToGLY;
touchWidth *= clientToGLX;
touchHeight *= clientToGLY;
touchWidth = Math.max(1, Math.min(touchWidth, MAX_TOUCH_SIZE[0]));
touchHeight = Math.max(1, Math.min(touchHeight, MAX_TOUCH_SIZE[1]));
const pixelLeft = Math.floor(centerX - Math.floor(touchWidth / 2) + 0.5);
const pixelRight = Math.floor(centerX + Math.ceil(touchWidth / 2) + 0.5);
const pixelTop = Math.floor(centerY - Math.floor(touchHeight / 2) + 0.5);
const pixelBottom = Math.floor(centerY + Math.ceil(touchHeight / 2) + 0.5);
twgl.bindFramebufferInfo(gl, this._pickBufferInfo);
gl.viewport(0, 0, touchWidth, touchHeight);
const noneColor = Drawable.color4fFromID(RenderConstants.ID_NONE);
gl.clearColor.apply(gl, noneColor);
gl.clear(gl.COLOR_BUFFER_BIT);
const widthPerPixel = (this._xRight - this._xLeft) / this._gl.canvas.width;
const heightPerPixel = (this._yBottom - this._yTop) / this._gl.canvas.height;
const pickLeft = this._xLeft + (pixelLeft * widthPerPixel);
const pickRight = this._xLeft + (pixelRight * widthPerPixel);
const pickTop = this._yTop + (pixelTop * heightPerPixel);
const pickBottom = this._yTop + (pixelBottom * heightPerPixel);
const projection = twgl.m4.ortho(pickLeft, pickRight, pickTop, pickBottom, -1, 1);
this._drawThese(candidateIDs, ShaderManager.DRAW_MODE.silhouette, projection);
const pixels = new Uint8Array(touchWidth * touchHeight * 4);
gl.readPixels(0, 0, touchWidth, touchHeight, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
if (this._debugCanvas) {
this._debugCanvas.width = touchWidth;
this._debugCanvas.height = touchHeight;
const context = this._debugCanvas.getContext('2d');
const imageData = context.getImageData(0, 0, touchWidth, touchHeight);
imageData.data.set(pixels);
context.putImageData(imageData, 0, 0);
}
const hits = {};
for (let pixelBase = 0; pixelBase < pixels.length; pixelBase += 4) {
const pixelID = Drawable.color3bToID(
pixels[pixelBase],
pixels[pixelBase + 1],
pixels[pixelBase + 2]);
hits[pixelID] = (hits[pixelID] || 0) + 1;
}
// Bias toward selecting anything over nothing
hits[RenderConstants.ID_NONE] = 0;
let hit = RenderConstants.ID_NONE;
for (const hitID in hits) {
if (hits.hasOwnProperty(hitID) && (hits[hitID] > hits[hit])) {
hit = hitID;
}
}
return hit | 0;
}
/**
* @typedef DrawableExtraction
* @property {Uint8Array} data Raw pixel data for the drawable
* @property {int} width Drawable bounding box width
* @property {int} height Drawable bounding box height
* @property {int} x The x coordinate relative to drawable bounding box
* @property {int} y The y coordinate relative to drawable bounding box
*/
/**
* Return drawable pixel data and picking coordinates relative to the drawable bounds
* @param {int} drawableID The ID of the drawable to get pixel data for
* @param {int} x The client x coordinate of the picking location.
* @param {int} y The client y coordinate of the picking location.
* @return {?DrawableExtraction} Data about the picked drawable
*/
extractDrawable (drawableID, x, y) {
const drawable = this._allDrawables[drawableID];
if (!drawable) return null;
const gl = this._gl;
twgl.bindFramebufferInfo(gl, this._queryBufferInfo);
const bounds = drawable.getFastBounds();
bounds.snapToInt();
// Translate input x and y to coordinates relative to the drawable
const pickX = x - ((this._nativeSize[0] / 2) + bounds.left);
const pickY = y - ((this._nativeSize[1] / 2) - bounds.top);
// 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.top, bounds.bottom, -1, 1);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
try {
gl.disable(gl.BLEND);
this._drawThese([drawableID], ShaderManager.DRAW_MODE.default, projection,
{effectMask: ~ShaderManager.EFFECT_INFO.ghost.mask});
} finally {
gl.enable(gl.BLEND);
}
const data = new Uint8Array(bounds.width * bounds.height * 4);
gl.readPixels(0, 0, bounds.width, bounds.height, gl.RGBA, gl.UNSIGNED_BYTE, data);
if (this._debugCanvas) {
this._debugCanvas.width = bounds.width;
this._debugCanvas.height = bounds.height;
const ctx = this._debugCanvas.getContext('2d');
const imageData = ctx.createImageData(bounds.width, bounds.height);
imageData.data.set(data);
ctx.putImageData(imageData, 0, 0);
ctx.beginPath();
ctx.arc(pickX, pickY, 3, 0, 2 * Math.PI, false);
ctx.fillStyle = 'white';
ctx.fill();
ctx.lineWidth = 1;
ctx.strokeStyle = 'black';
ctx.stroke();
}
return {
data: data,
width: bounds.width,
height: bounds.height,
x: pickX,
y: pickY
};
}
/**
* 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.
*/
_touchingBounds (drawableID) {
const drawable = this._allDrawables[drawableID];
/** @todo remove this once URL-based skin setting is removed. */
if (!drawable.skin || !drawable.skin.getTexture([100, 100])) return null;
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 - Bounds to limit candidates to.
* @return {?Array<int>} Filtered candidateIDs, or null if none.
*/
_filterCandidatesTouching (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(testID => {
if (testID === drawableID) return false;
// Only draw items which could possibly overlap target Drawable.
const candidate = this._allDrawables[testID];
const 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.
* @param {object.<string,*>} properties The new property values to set.
*/
updateDrawableProperties (drawableID, properties) {
const drawable = this._allDrawables[drawableID];
if (!drawable) {
/**
* @todo fix whatever's wrong in the VM which causes this, then add a warning or throw here.
* Right now this happens so much on some projects that a warning or exception here can hang the browser.
*/
return;
}
/** @todo remove this after fully deprecating URL-based skin paths */
if ('skin' in properties) {
const {skin, costumeResolution, rotationCenter} = properties;
const skinId = this.createSkinFromURL(skin, costumeResolution, rotationCenter);
drawable.skin = this._allSkins[skinId];
}
if ('skinId' in properties) {
drawable.skin = this._allSkins[properties.skinId];
}
if ('rotationCenter' in properties) {
const newRotationCenter = properties.rotationCenter;
drawable.skin.setRotationCenter(newRotationCenter[0], newRotationCenter[1]);
}
drawable.updateProperties(properties);
}
/**
* Update the position object's x & y members to keep the drawable fenced in view.
* @param {int} drawableID - The ID of the Drawable to update.
* @param {Array.<number, number>} position to be fenced - An array of type [x, y]
* @return {Array.<number, number>} The fenced position as an array [x, y]
*/
getFencedPositionOfDrawable (drawableID, position) {
let x = position[0];
let y = position[1];
const drawable = this._allDrawables[drawableID];
if (!drawable) {
// TODO: fix whatever's wrong in the VM which causes this, then add a warning or throw here.
// Right now this happens so much on some projects that a warning or exception here can hang the browser.
return [x, y];
}
const dx = x - drawable._position[0];
const dy = y - drawable._position[1];
const aabb = drawable.getFastBounds();
const sx = this._xRight - Math.min(FENCE_WIDTH, Math.floor((aabb.right - aabb.left) / 2));
if (aabb.right + dx < -sx) {
x = drawable._position[0] - (sx + aabb.right);
} else if (aabb.left + dx > sx) {
x = drawable._position[0] + (sx - aabb.left);
}
const sy = this._yTop - Math.min(FENCE_WIDTH, Math.floor((aabb.top - aabb.bottom) / 2));
if (aabb.top + dy < -sy) {
y = drawable._position[1] - (sy + aabb.top);
} else if (aabb.bottom + dy > sy) {
y = drawable._position[1] + (sy - aabb.bottom);
}
return [x, y];
}
/**
* Clear a pen layer.
* @param {int} penSkinID - the unique ID of a Pen Skin.
*/
penClear (penSkinID) {
const skin = /** @type {PenSkin} */ this._allSkins[penSkinID];
skin.clear();
}
/**
* Draw a point on a pen layer.
* @param {int} penSkinID - the unique ID of a Pen Skin.
* @param {PenAttributes} penAttributes - how the point should be drawn.
* @param {number} x - the X coordinate of the point to draw.
* @param {number} y - the Y coordinate of the point to draw.
*/
penPoint (penSkinID, penAttributes, x, y) {
const skin = /** @type {PenSkin} */ this._allSkins[penSkinID];
skin.drawPoint(penAttributes, x, y);
}
/**
* Draw a line on a pen layer.
* @param {int} penSkinID - the unique ID of a Pen Skin.
* @param {PenAttributes} penAttributes - how the line should be drawn.
* @param {number} x0 - the X coordinate of the beginning of the line.
* @param {number} y0 - the Y coordinate of the beginning of the line.
* @param {number} x1 - the X coordinate of the end of the line.
* @param {number} y1 - the Y coordinate of the end of the line.
*/
penLine (penSkinID, penAttributes, x0, y0, x1, y1) {
const skin = /** @type {PenSkin} */ this._allSkins[penSkinID];
skin.drawLine(penAttributes, x0, y0, x1, y1);
}
/**
* Stamp a Drawable onto a pen layer.
* @param {int} penSkinID - the unique ID of a Pen Skin.
* @param {int} stampID - the unique ID of the Drawable to use as the stamp.
*/
penStamp (penSkinID, stampID) {
const stampDrawable = this._allDrawables[stampID];
if (!stampDrawable) {
return;
}
const bounds = this._touchingBounds(stampID);
if (!bounds) {
return;
}
const skin = /** @type {PenSkin} */ this._allSkins[penSkinID];
const gl = this._gl;
twgl.bindFramebufferInfo(gl, this._queryBufferInfo);
// Limit size of viewport to the bounds around the stamp 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.top, bounds.bottom, -1, 1);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
try {
gl.disable(gl.BLEND);
this._drawThese([stampID], ShaderManager.DRAW_MODE.default, projection);
} finally {
gl.enable(gl.BLEND);
}
const stampPixels = new Uint8Array(bounds.width * bounds.height * 4);
gl.readPixels(0, 0, bounds.width, bounds.height, gl.RGBA, gl.UNSIGNED_BYTE, stampPixels);
const stampCanvas = this._tempCanvas;
stampCanvas.width = bounds.width;
stampCanvas.height = bounds.height;
const stampContext = stampCanvas.getContext('2d');
const stampImageData = stampContext.createImageData(bounds.width, bounds.height);
stampImageData.data.set(stampPixels);
stampContext.putImageData(stampImageData, 0, 0);
skin.drawStamp(stampCanvas, bounds.left, bounds.top);
}
/* ******
* Truly internal functions: these support the functions above.
********/
/**
* Build geometry (vertex and index) buffers.
* @private
*/
_createGeometry () {
const quad = {
a_position: {
numComponents: 2,
data: [
-0.5, -0.5,
0.5, -0.5,
-0.5, 0.5,
-0.5, 0.5,
0.5, -0.5,
0.5, 0.5
]
},
a_texCoord: {
numComponents: 2,
data: [
1, 0,
0, 0,
1, 1,
1, 1,
0, 0,
0, 1
]
}
};
this._bufferInfo = twgl.createBufferInfoFromArrays(this._gl, quad);
}
/**
* Respond to a change in the "native" rendering size. The native size is used by buffers which are fixed in size
* regardless of the size of the main render target. This includes the buffers used for queries such as picking and
* color-touching. The fixed size allows (more) consistent behavior across devices and presentation modes.
* @param {object} event - The change event.
* @private
*/
onNativeSizeChanged (event) {
const [width, height] = event.newSize;
const gl = this._gl;
const attachments = [
{format: gl.RGBA},
{format: gl.DEPTH_STENCIL}
];
if (!this._pickBufferInfo) {
this._pickBufferInfo = twgl.createFramebufferInfo(gl, attachments, MAX_TOUCH_SIZE[0], MAX_TOUCH_SIZE[1]);
}
/** @todo should we create this on demand to save memory? */
// A 480x360 32-bpp buffer is 675 KiB.
if (this._queryBufferInfo) {
twgl.resizeFramebufferInfo(gl, this._queryBufferInfo, attachments, width, height);
} else {
this._queryBufferInfo = twgl.createFramebufferInfo(gl, attachments, width, height);
}
}
/**
* Draw a set of Drawables, by drawable ID
* @param {Array<int>} drawables The Drawable IDs to draw, possibly this._drawList.
* @param {ShaderManager.DRAW_MODE} drawMode Draw normally, silhouette, etc.
* @param {module:twgl/m4.Mat4} projection The projection matrix to use.
* @param {object} [opts] Options for drawing
* @param {idFilterFunc} opts.filter An optional filter function.
* @param {object.<string,*>} opts.extraUniforms Extra uniforms for the shaders.
* @param {int} opts.effectMask Bitmask for effects to allow
* @private
*/
_drawThese (drawables, drawMode, projection, opts = {}) {
const gl = this._gl;
let currentShader = null;
const numDrawables = drawables.length;
for (let drawableIndex = 0; drawableIndex < numDrawables; ++drawableIndex) {
const drawableID = drawables[drawableIndex];
// If we have a filter, check whether the ID fails
if (opts.filter && !opts.filter(drawableID)) continue;
const drawable = this._allDrawables[drawableID];
/** @todo check if drawable is inside the viewport before anything else */
// Hidden drawables (e.g., by a "hide" block) are never drawn.
if (!drawable.getVisible()) continue;
const drawableScale = drawable.scale;
// If the texture isn't ready yet, skip it.
if (!drawable.skin.getTexture(drawableScale)) continue;
let effectBits = drawable.getEnabledEffects();
effectBits &= opts.hasOwnProperty('effectMask') ? opts.effectMask : effectBits;
const newShader = this._shaderManager.getShader(drawMode, effectBits);
if (currentShader !== newShader) {
currentShader = newShader;
gl.useProgram(currentShader.program);
twgl.setBuffersAndAttributes(gl, currentShader, this._bufferInfo);
twgl.setUniforms(currentShader, {u_projectionMatrix: projection});
twgl.setUniforms(currentShader, {u_fudge: window.fudge || 0});
}
twgl.setUniforms(currentShader, drawable.skin.getUniforms(drawableScale));
twgl.setUniforms(currentShader, drawable.getUniforms());
// Apply extra uniforms after the Drawable's, to allow overwriting.
if (opts.extraUniforms) {
twgl.setUniforms(currentShader, opts.extraUniforms);
}
twgl.drawBufferInfo(gl, this._bufferInfo, gl.TRIANGLES);
}
}
/**
* Get the convex hull points for a particular Drawable.
* To do this, draw the Drawable unrotated, unscaled, and untranslated.
* Read back the pixels and find all boundary points.
* Finally, apply a convex hull algorithm to simplify the set.
* @param {int} drawableID The Drawable IDs calculate convex hull for.
* @return {Array<Array<number>>} points Convex hull points, as [[x, y], ...]
*/
_getConvexHullPointsForDrawable (drawableID) {
const drawable = this._allDrawables[drawableID];
const [width, height] = drawable.skin.size;
// No points in the hull if invisible or size is 0.
if (!drawable.getVisible() || width === 0 || height === 0) {
return [];
}
// Only draw to the size of the untransformed drawable.
const gl = this._gl;
twgl.bindFramebufferInfo(gl, this._queryBufferInfo);
gl.viewport(0, 0, width, height);
// Clear the canvas with RenderConstants.ID_NONE.
const noneColor = Drawable.color4fFromID(RenderConstants.ID_NONE);
gl.clearColor.apply(gl, noneColor);
gl.clear(gl.COLOR_BUFFER_BIT);
// Overwrite the model matrix to be unrotated, unscaled, untranslated.
const modelMatrix = twgl.m4.identity();
twgl.m4.rotateZ(modelMatrix, Math.PI, modelMatrix);
twgl.m4.scale(modelMatrix, [width, height], modelMatrix);
const projection = twgl.m4.ortho(-0.5 * width, 0.5 * width, -0.5 * height, 0.5 * height, -1, 1);
this._drawThese([drawableID],
ShaderManager.DRAW_MODE.silhouette,
projection,
{extraUniforms: {u_modelMatrix: modelMatrix}}
);
const pixels = new Uint8Array(width * height * 4);
gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
// Known boundary points on left/right edges of pixels.
const boundaryPoints = [];
/**
* Helper method to look up a pixel.
* @param {int} x X coordinate of the pixel in `pixels`.
* @param {int} y Y coordinate of the pixel in `pixels`.
* @return {int} Known ID at that pixel, or RenderConstants.ID_NONE.
*/
const _getPixel = (x, y) => {
const pixelBase = Math.round(((width * y) + x) * 4); // Sometimes SVGs don't have int width and height
return Drawable.color3bToID(
pixels[pixelBase],
pixels[pixelBase + 1],
pixels[pixelBase + 2]);
};
for (let y = 0; y <= height; y++) {
// Scan from left.
for (let x = 0; x < width; x++) {
if (_getPixel(x, y) > RenderConstants.ID_NONE) {
boundaryPoints.push([x, y]);
break;
}
}
// Scan from right.
for (let x = width - 1; x >= 0; x--) {
if (_getPixel(x, y) > RenderConstants.ID_NONE) {
boundaryPoints.push([x, y]);
break;
}
}
}
// Simplify boundary points using convex hull.
return hull(boundaryPoints, Infinity);
}
}
// :3
RenderWebGL.prototype.canHazPixels = RenderWebGL.prototype.extractDrawable;
module.exports = RenderWebGL;