scratch-render
Version:
WebGL Renderer for Scratch 3.0
1,227 lines (1,063 loc) • 82.9 kB
JavaScript
const EventEmitter = require('events');
const hull = require('hull.js');
const twgl = require('twgl.js');
const BitmapSkin = require('./BitmapSkin');
const Drawable = require('./Drawable');
const Rectangle = require('./Rectangle');
const PenSkin = require('./PenSkin');
const RenderConstants = require('./RenderConstants');
const ShaderManager = require('./ShaderManager');
const SVGSkin = require('./SVGSkin');
const TextBubbleSkin = require('./TextBubbleSkin');
const EffectTransform = require('./EffectTransform');
const log = require('./util/log');
const __isTouchingDrawablesPoint = twgl.v3.create();
const __candidatesBounds = new Rectangle();
const __fenceBounds = 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
* @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];
/**
* Passed to the uniforms for mask in touching color
*/
const MASK_TOUCHING_COLOR_TOLERANCE = 2;
/**
* Maximum number of pixels in either dimension of "extracted drawable" data
* @type {int}
*/
const MAX_EXTRACTED_DRAWABLE_DIMENSION = 2048;
/**
* 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 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
* onscreen around the edge of the staging area.
* @type {number}
*/
const FENCE_WIDTH = 15;
class RenderWebGL extends EventEmitter {
/**
* Check if this environment appears to support this renderer before attempting to create an instance.
* Catching an exception from the constructor is also a valid way to test for (lack of) support.
* @param {canvas} [optCanvas] - An optional canvas to use for the test. Otherwise a temporary canvas will be used.
* @returns {boolean} - True if this environment appears to support this renderer, false otherwise.
*/
static isSupported (optCanvas) {
try {
// Create the context the same way that the constructor will: attributes may make the difference.
return !!RenderWebGL._getContext(optCanvas || document.createElement('canvas'));
} catch (e) {
return false;
}
}
/**
* Ask TWGL to create a rendering context with the attributes used by this renderer.
* @param {canvas} canvas - attach the context to this canvas.
* @returns {WebGLRenderingContext} - a TWGL rendering context (backed by either WebGL 1.0 or 2.0).
* @private
*/
static _getContext (canvas) {
const contextAttribs = {alpha: false, stencil: true, antialias: false};
// getWebGLContext = try WebGL 1.0 only
// getContext = try WebGL 2.0 and if that doesn't work, try WebGL 1.0
// getWebGLContext || getContext = try WebGL 1.0 and if that doesn't work, try WebGL 2.0
return twgl.getWebGLContext(canvas, contextAttribs) ||
twgl.getContext(canvas, contextAttribs);
}
/**
* 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 {WebGLRenderingContext} */
const gl = this._gl = RenderWebGL._getContext(canvas);
if (!gl) {
throw new Error('Could not get WebGL context: this browser or environment may not support WebGL.');
}
/** @type {RenderWebGL.UseGpuModes} */
this._useGpuMode = RenderWebGL.UseGpuModes.Automatic;
/** @type {Drawable[]} */
this._allDrawables = [];
/** @type {Skin[]} */
this._allSkins = [];
/** @type {Array<int>} */
this._drawList = [];
// A list of layer group names in the order they should appear
// from furthest back to furthest in front.
/** @type {Array<String>} */
this._groupOrdering = [];
/**
* @typedef LayerGroup
* @property {int} groupIndex The relative position of this layer group in the group ordering
* @property {int} drawListOffset The absolute position of this layer group in the draw list
* This number gets updated as drawables get added to or deleted from the draw list.
*/
// Map of group name to layer group
/** @type {Object.<string, LayerGroup>} */
this._layerGroups = {};
/** @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 {ShaderManager} */
this._shaderManager = new ShaderManager(gl);
/** @type {HTMLCanvasElement} */
this._tempCanvas = document.createElement('canvas');
/** @type {any} */
this._regionId = null;
/** @type {function} */
this._exitRegion = null;
/** @type {object} */
this._backgroundDrawRegionId = {
enter: () => this._enterDrawBackground(),
exit: () => this._exitDrawBackground()
};
/** @type {Array.<snapshotCallback>} */
this._snapshotCallbacks = [];
/** @type {Array<number>} */
// Don't set this directly-- use setBackgroundColor so it stays in sync with _backgroundColor3b
this._backgroundColor4f = [0, 0, 0, 1];
/** @type {Uint8ClampedArray} */
// Don't set this directly-- use setBackgroundColor so it stays in sync with _backgroundColor4f
this._backgroundColor3b = new Uint8ClampedArray(3);
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.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
}
/**
* @returns {WebGLRenderingContext} the WebGL rendering context associated with this renderer.
*/
get gl () {
return this._gl;
}
/**
* @returns {HTMLCanvasElement} the canvas of the WebGL rendering context associated with this renderer.
*/
get canvas () {
return this._gl && this._gl.canvas;
}
/**
* 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 {canvas} = this._gl;
const pixelRatio = window.devicePixelRatio || 1;
const newWidth = pixelsWide * pixelRatio;
const newHeight = pixelsTall * pixelRatio;
// Certain operations, such as moving the color picker, call `resize` once per frame, even though the canvas
// size doesn't change. To avoid unnecessary canvas updates, check that we *really* need to resize the canvas.
if (canvas.width !== newWidth || canvas.height !== newHeight) {
canvas.width = newWidth;
canvas.height = newHeight;
// Resizing the canvas causes it to be cleared, so redraw it.
this.draw();
}
}
/**
* 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._backgroundColor4f[0] = red;
this._backgroundColor4f[1] = green;
this._backgroundColor4f[2] = blue;
this._backgroundColor3b[0] = red * 255;
this._backgroundColor3b[1] = green * 255;
this._backgroundColor3b[2] = blue * 255;
}
/**
* 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;
}
/**
* Control the use of the GPU or CPU paths in `isTouchingColor`.
* @param {RenderWebGL.UseGpuModes} useGpuMode - automatically decide, force CPU, or force GPU.
*/
setUseGpuMode (useGpuMode) {
this._useGpuMode = useGpuMode;
}
/**
* 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 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;
}
/**
* Create a new SVG skin using the text bubble svg creator. The rotation center
* is always placed at the top left.
* @param {!string} type - either "say" or "think".
* @param {!string} text - the text for the bubble.
* @param {!boolean} pointsLeft - which side the bubble is pointing.
* @returns {!int} the ID for the new skin.
*/
createTextSkin (type, text, pointsLeft) {
const skinId = this._nextSkinId++;
const newSkin = new TextBubbleSkin(skinId, this);
newSkin.setTextBubble(type, text, pointsLeft);
this._allSkins[skinId] = newSkin;
return skinId;
}
/**
* Update an existing SVG skin, or create an SVG skin if the previous skin was not SVG.
* @param {!int} skinId the ID for the skin to change.
* @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
*/
updateSVGSkin (skinId, svgData, rotationCenter) {
if (this._allSkins[skinId] instanceof SVGSkin) {
this._allSkins[skinId].setSVG(svgData, rotationCenter);
return;
}
const newSkin = new SVGSkin(skinId, this);
newSkin.setSVG(svgData, rotationCenter);
this._reskin(skinId, newSkin);
}
/**
* Update an existing bitmap skin, or create a bitmap skin if the previous skin was not bitmap.
* @param {!int} skinId the ID for the skin to change.
* @param {!ImageData|HTMLImageElement|HTMLCanvasElement|HTMLVideoElement} imgData - new contents for this skin.
* @param {!number} bitmapResolution - the resolution scale for a bitmap costume.
* @param {?Array<number>} rotationCenter Optional: rotation center of the skin. If not supplied, the center of the
* skin will be used
*/
updateBitmapSkin (skinId, imgData, bitmapResolution, rotationCenter) {
if (this._allSkins[skinId] instanceof BitmapSkin) {
this._allSkins[skinId].setBitmap(imgData, bitmapResolution, rotationCenter);
return;
}
const newSkin = new BitmapSkin(skinId, this);
newSkin.setBitmap(imgData, bitmapResolution, rotationCenter);
this._reskin(skinId, newSkin);
}
_reskin (skinId, newSkin) {
const oldSkin = this._allSkins[skinId];
this._allSkins[skinId] = newSkin;
// Tell drawables to update
for (const drawable of this._allDrawables) {
if (drawable && drawable.skin === oldSkin) {
drawable.skin = newSkin;
}
}
oldSkin.dispose();
}
/**
* Update a skin using the text bubble svg creator.
* @param {!int} skinId the ID for the skin to change.
* @param {!string} type - either "say" or "think".
* @param {!string} text - the text for the bubble.
* @param {!boolean} pointsLeft - which side the bubble is pointing.
*/
updateTextSkin (skinId, type, text, pointsLeft) {
if (this._allSkins[skinId] instanceof TextBubbleSkin) {
this._allSkins[skinId].setTextBubble(type, text, pointsLeft);
return;
}
const newSkin = new TextBubbleSkin(skinId, this);
newSkin.setTextBubble(type, text, pointsLeft);
this._reskin(skinId, newSkin);
}
/**
* 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.
* @param {string} group Layer group to add the drawable to
* @returns {int} The ID of the new Drawable.
*/
createDrawable (group) {
if (!group || !Object.prototype.hasOwnProperty.call(this._layerGroups, group)) {
log.warn('Cannot create a drawable without a known layer group');
return;
}
const drawableID = this._nextDrawableId++;
const drawable = new Drawable(drawableID);
this._allDrawables[drawableID] = drawable;
this._addToDrawList(drawableID, group);
drawable.skin = null;
return drawableID;
}
/**
* Set the layer group ordering for the renderer.
* @param {Array<string>} groupOrdering The ordered array of layer group
* names
*/
setLayerGroupOrdering (groupOrdering) {
this._groupOrdering = groupOrdering;
for (let i = 0; i < this._groupOrdering.length; i++) {
this._layerGroups[this._groupOrdering[i]] = {
groupIndex: i,
drawListOffset: 0
};
}
}
_addToDrawList (drawableID, group) {
const currentLayerGroup = this._layerGroups[group];
const currentGroupOrderingIndex = currentLayerGroup.groupIndex;
const drawListOffset = this._endIndexForKnownLayerGroup(currentLayerGroup);
this._drawList.splice(drawListOffset, 0, drawableID);
this._updateOffsets('add', currentGroupOrderingIndex);
}
_updateOffsets (updateType, currentGroupOrderingIndex) {
for (let i = currentGroupOrderingIndex + 1; i < this._groupOrdering.length; i++) {
const laterGroupName = this._groupOrdering[i];
if (updateType === 'add') {
this._layerGroups[laterGroupName].drawListOffset++;
} else if (updateType === 'delete'){
this._layerGroups[laterGroupName].drawListOffset--;
}
}
}
get _visibleDrawList () {
return this._drawList.filter(id => this._allDrawables[id]._visible);
}
// Given a layer group, return the index where it ends (non-inclusive),
// e.g. the returned index does not have a drawable from this layer group in it)
_endIndexForKnownLayerGroup (layerGroup) {
const groupIndex = layerGroup.groupIndex;
if (groupIndex === this._groupOrdering.length - 1) {
return this._drawList.length;
}
return this._layerGroups[this._groupOrdering[groupIndex + 1]].drawListOffset;
}
/**
* Destroy a Drawable, removing it from the scene.
* @param {int} drawableID The ID of the Drawable to remove.
* @param {string} group Group name that the drawable belongs to
*/
destroyDrawable (drawableID, group) {
if (!group || !Object.prototype.hasOwnProperty.call(this._layerGroups, group)) {
log.warn('Cannot destroy drawable without known layer group.');
return;
}
const drawable = this._allDrawables[drawableID];
drawable.dispose();
delete this._allDrawables[drawableID];
const currentLayerGroup = this._layerGroups[group];
const endIndex = this._endIndexForKnownLayerGroup(currentLayerGroup);
let index = currentLayerGroup.drawListOffset;
while (index < endIndex) {
if (this._drawList[index] === drawableID) {
break;
}
index++;
}
if (index < endIndex) {
this._drawList.splice(index, 1);
this._updateOffsets('delete', currentLayerGroup.groupIndex);
} else {
log.warn('Could not destroy drawable that could not be found in layer group.');
return;
}
}
/**
* Returns the position of the given drawableID in the draw list. This is
* the absolute position irrespective of layer group.
* @param {number} drawableID The drawable ID to find.
* @return {number} The postion of the given drawable ID.
*/
getDrawableOrder (drawableID) {
return this._drawList.indexOf(drawableID);
}
/**
* 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 {string=} group Name of layer group drawable belongs to.
* Reordering will not take place if drawable cannot be found within the bounds
* of the layer group.
* @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, group, optIsRelative, optMin) {
if (!group || !Object.prototype.hasOwnProperty.call(this._layerGroups, group)) {
log.warn('Cannot set the order of a drawable without a known layer group.');
return;
}
const currentLayerGroup = this._layerGroups[group];
const startIndex = currentLayerGroup.drawListOffset;
const endIndex = this._endIndexForKnownLayerGroup(currentLayerGroup);
let oldIndex = startIndex;
while (oldIndex < endIndex) {
if (this._drawList[oldIndex] === drawableID) {
break;
}
oldIndex++;
}
if (oldIndex < endIndex) {
// Remove drawable from the list.
if (order === 0) {
return oldIndex;
}
const _ = this._drawList.splice(oldIndex, 1)[0];
// Determine new index.
let newIndex = order;
if (optIsRelative) {
newIndex += oldIndex;
}
const possibleMin = (optMin || 0) + startIndex;
const min = (possibleMin >= startIndex && possibleMin < endIndex) ? possibleMin : startIndex;
newIndex = Math.max(newIndex, min);
newIndex = Math.min(newIndex, endIndex);
// Insert at new index.
this._drawList.splice(newIndex, 0, drawableID);
return newIndex;
}
return null;
}
/**
* Draw all current drawables and present the frame on the canvas.
*/
draw () {
this._doExitDrawRegion();
const gl = this._gl;
twgl.bindFramebufferInfo(gl, null);
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
gl.clearColor(...this._backgroundColor4f);
gl.clear(gl.COLOR_BUFFER_BIT);
this._drawThese(this._drawList, ShaderManager.DRAW_MODE.default, this._projection, {
framebufferWidth: gl.canvas.width,
framebufferHeight: gl.canvas.height
});
if (this._snapshotCallbacks.length > 0) {
const snapshot = gl.canvas.toDataURL();
this._snapshotCallbacks.forEach(cb => cb(snapshot));
this._snapshotCallbacks = [];
}
}
/**
* 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.getFastBounds();
// 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 precise bounds for a Drawable around the top slice.
* Used for positioning speech bubbles more closely to the sprite.
* @param {int} drawableID ID of Drawable to get bubble bounds for.
* @return {object} Bounds for a tight box around the Drawable top slice.
*/
getBoundsForBubble (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.getBoundsForBubble();
// 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.
*/
getCurrentSkinSize (drawableID) {
const drawable = this._allDrawables[drawableID];
return this.getSkinSize(drawable.skin.id);
}
/**
* Get the size of a skin by ID.
* @param {int} skinID The ID of the Skin to measure.
* @return {Array<number>} Skin size, width and height.
*/
getSkinSize (skinID) {
const skin = this._allSkins[skinID];
return skin.size;
}
/**
* Get the rotation center of a skin by ID.
* @param {int} skinID The ID of the Skin
* @return {Array<number>} The rotationCenterX and rotationCenterY
*/
getSkinRotationCenter (skinID) {
const skin = this._allSkins[skinID];
return skin.calculateRotationCenter();
}
/**
* Check if a particular Drawable is touching a particular color.
* Unlike touching drawable, if the "tester" is invisble, we will still test.
* @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 candidates = this._candidatesTouching(drawableID, this._visibleDrawList);
let bounds;
if (colorMatches(color3b, this._backgroundColor3b, 0)) {
// If the color we're checking for is the background color, don't confine the check to
// candidate drawables' bounds--since the background spans the entire stage, we must check
// everything that lies inside the drawable.
bounds = this._touchingBounds(drawableID);
// e.g. empty costume, or off the stage
if (bounds === null) return false;
} else if (candidates.length === 0) {
// If not checking for the background color, we can return early if there are no candidate drawables.
return false;
} else {
bounds = this._candidatesBounds(candidates);
}
const maxPixelsForCPU = this._getMaxPixelsForCPU();
const debugCanvasContext = this._debugCanvas && this._debugCanvas.getContext('2d');
if (debugCanvasContext) {
this._debugCanvas.width = bounds.width;
this._debugCanvas.height = bounds.height;
}
// if there are just too many pixels to CPU render efficiently, we need to let readPixels happen
if (bounds.width * bounds.height * (candidates.length + 1) >= maxPixelsForCPU) {
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);
drawable.updateCPURenderAttributes();
// Masked drawable ignores ghost effect
const effectMask = ~ShaderManager.EFFECT_INFO.ghost.mask;
// Scratch Space - +y is top
for (let y = bounds.bottom; y <= bounds.top; y++) {
if (bounds.width * (y - bounds.bottom) * (candidates.length + 1) >= maxPixelsForCPU) {
return this._isTouchingColorGpuFin(bounds, color3b, y - bounds.bottom);
}
for (let x = bounds.left; x <= bounds.right; x++) {
point[1] = y;
point[0] = x;
// if we use a mask, check our sample color...
if (hasMask ?
maskMatches(Drawable.sampleColor4b(point, drawable, color, effectMask), mask3b) :
drawable.isTouching(point)) {
RenderWebGL.sampleColor3b(point, candidates, color);
if (debugCanvasContext) {
debugCanvasContext.fillStyle = `rgb(${color[0]},${color[1]},${color[2]})`;
debugCanvasContext.fillRect(x - bounds.left, bounds.bottom - y, 1, 1);
}
// ...and the target color is drawn at this pixel
if (colorMatches(color, color3b, 0)) {
return true;
}
}
}
}
return false;
}
_getMaxPixelsForCPU () {
switch (this._useGpuMode) {
case RenderWebGL.UseGpuModes.ForceCPU:
return Infinity;
case RenderWebGL.UseGpuModes.ForceGPU:
return 0;
case RenderWebGL.UseGpuModes.Automatic:
default:
return __cpuTouchingColorPixelCount;
}
}
_enterDrawBackground () {
const gl = this.gl;
const currentShader = this._shaderManager.getShader(ShaderManager.DRAW_MODE.background, 0);
gl.disable(gl.BLEND);
gl.useProgram(currentShader.program);
twgl.setBuffersAndAttributes(gl, currentShader, this._bufferInfo);
}
_exitDrawBackground () {
const gl = this.gl;
gl.enable(gl.BLEND);
}
_isTouchingColorGpuStart (drawableID, candidateIDs, bounds, color3b, mask3b) {
this._doExitDrawRegion();
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.
gl.viewport(0, 0, bounds.width, bounds.height);
const projection = twgl.m4.ortho(bounds.left, bounds.right, bounds.top, bounds.bottom, -1, 1);
// Clear the query buffer to fully transparent. This will be the color of pixels that fail the stencil test.
gl.clearColor(0, 0, 0, 0);
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: MASK_TOUCHING_COLOR_TOLERANCE / 255
};
}
try {
// Using the stencil buffer, mask out the drawing to either the drawable's alpha channel
// or pixels of the drawable which match the mask color, depending on whether a mask color is given.
// Masked-out pixels will not be checked.
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,
ignoreVisibility: true, // Touching color ignores sprite visibility,
effectMask: ~ShaderManager.EFFECT_INFO.ghost.mask
});
gl.stencilFunc(gl.EQUAL, 1, 1);
gl.stencilOp(gl.KEEP, gl.KEEP, gl.KEEP);
gl.colorMask(true, true, true, true);
// Draw the background as a quad. Drawing a background with gl.clear will not mask to the stenciled area.
this.enterDrawRegion(this._backgroundDrawRegionId);
const uniforms = {
u_backgroundColor: this._backgroundColor4f
};
const currentShader = this._shaderManager.getShader(ShaderManager.DRAW_MODE.background, 0);
twgl.setUniforms(currentShader, uniforms);
twgl.drawBufferInfo(gl, this._bufferInfo, gl.TRIANGLES);
// Draw the candidate drawables on top of the background.
this._drawThese(candidateIDs, ShaderManager.DRAW_MODE.default, projection,
{idFilterFunc: testID => testID !== drawableID}
);
} finally {
gl.colorMask(true, true, true, true);
gl.disable(gl.STENCIL_TEST);
this._doExitDrawRegion();
}
}
_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 - stop);
imageData.data.set(pixels);
context.putImageData(imageData, 0, 0);
}
for (let pixelBase = 0; pixelBase < pixels.length; pixelBase += 4) {
// Transparent pixels are masked (either by the drawable's alpha channel or color mask).
if (pixels[pixelBase + 3] !== 0 && colorMatches(color3b, pixels, pixelBase)) {
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 visible drawables in the renderer
* @returns {boolean} True if the Drawable is touching one of candidateIDs.
*/
isTouchingDrawables (drawableID, candidateIDs = this._drawList) {
const candidates = this._candidatesTouching(drawableID,
// even if passed an invisible drawable, we will NEVER touch it!
candidateIDs.filter(id => this._allDrawables[id]._visible));
// if we are invisble we don't touch anything.
if (candidates.length === 0 || !this._allDrawables[drawableID]._visible) {
return false;
}
// Get the union of all the candidates intersections.
const bounds = this._candidatesBounds(candidates);
const drawable = this._allDrawables[drawableID];
const point = __isTouchingDrawablesPoint;
drawable.updateCPURenderAttributes();
// This is an EXTREMELY brute force collision detector, but it is
// still faster than asking the GPU to give us the pixels.
for (let x = bounds.left; x <= bounds.right; x++) {
// Scratch Space - +y is top
point[0] = x;
for (let y = bounds.bottom; y <= bounds.top; y++) {
point[1] = y;
if (drawable.isTouching(point)) {
for (let index = 0; index < candidates.length; index++) {
if (candidates[index].drawable.isTouching(point)) {
return true;
}
}
}
}
}
return false;
}
/**
* Convert a client based x/y position on the canvas to a Scratch 3 world space
* Rectangle. This creates recangles with a radius to cover selecting multiple
* scratch pixels with touch / small render areas.
*
* @param {int} centerX The client x coordinate of the picking location.
* @param {int} centerY The client y coordinate of the picking location.
* @param {int} [width] The client width of the touch event (optional).
* @param {int} [height] The client width of the touch event (optional).
* @returns {Rectangle} Scratch world space rectangle, iterate bottom <= top,
* left <= right.
*/
clientSpaceToScratchBounds (centerX, centerY, width = 1, height = 1) {
const gl = this._gl;
const clientToScratchX = this._nativeSize[0] / gl.canvas.clientWidth;
const clientToScratchY = this._nativeSize[1] / gl.canvas.clientHeight;
width *= clientToScratchX;
height *= clientToScratchY;
width = Math.max(1, Math.min(Math.round(width), MAX_TOUCH_SIZE[0]));
height = Math.max(1, Math.min(Math.round(height), MAX_TOUCH_SIZE[1]));
const x = (centerX * clientToScratchX) - ((width - 1) / 2);
// + because scratch y is inverted
const y = (centerY * clientToScratchY) + ((height - 1) / 2);
const xOfs = (width % 2) ? 0 : -0.5;
// y is offset +0.5
const yOfs = (height % 2) ? 0 : -0.5;
const bounds = new Rectangle();
bounds.initFromBounds(Math.floor(this._xLeft + x + xOfs), Math.floor(this._xLeft + x + xOfs + width - 1),
Math.ceil(this._yTop - y + yOfs), Math.ceil(this._yTop - y + yOfs + height - 1));
return bounds;
}
/**
* Determine if the drawable is touching a client based x/y. Helper method for sensing
* touching mouse-pointer. Ignores visibility.
*
* @param {int} drawableID The ID of the drawable to check.
* @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).
* @returns {boolean} If the drawable has any pixels that would draw in the touch area
*/
drawableTouching (drawableID, centerX, centerY, touchWidth, touchHeight) {
const drawable = this._allDrawables[drawableID];
if (!drawable) {
return false;
}
const bounds = this.clientSpaceToScratchBounds(centerX, centerY, touchWidth, touchHeight);
const worldPos = twgl.v3.create();
drawable.updateCPURenderAttributes();
for (worldPos[1] = bounds.bottom; worldPos[1] <= bounds.top; worldPos[1]++) {
for (worldPos[0] = bounds.left; worldPos[0] <= bounds.right; worldPos[0]++) {
if (drawable.isTouching(worldPos)) {
return true;
}
}
}
return false;
}
/**
* Detect which sprite, if any, is at the given location.
* This function will pick all drawables that are visible, unless specific
* candidate drawable IDs are provided. Used for determining what is clicked
* or dragged. Will not select hidden / ghosted sprites.
*
* @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 visible drawables.
* @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 bounds = this.clientSpaceToScratchBounds(centerX, centerY, touchWidth, touchHeight);
if (bounds.left === -Infinity || bounds.bottom === -Infinity) {
return false;
}
candidateIDs = (candidateIDs || this._drawList).filter(id => {
const drawable = this._allDrawables[id];
// default pick list ignores visible and ghosted sprites.
if (drawable.getVisible() && drawable.getUniforms().u_ghost !== 0) {
const drawableBounds = drawable.getFastBounds();
const inRange = bounds.intersects(drawableBounds);
if (!inRange) return false;
drawable.updateCPURenderAttributes();
return true;
}
return false;
});
if (candidateIDs.length === 0) {
return false;
}
const hits = [];
const worldPos = twgl.v3.create(0, 0, 0);
// Iterate over the scratch pixels and check if any candidate can be
// touched at that point.
for (worldPos[1] = bounds.bottom; worldPos[1] <= bounds.top; worldPos[1]++) {
for (worldPos[0] = bounds.left; worldPos[0] <= bounds.right; worldPos[0]++) {
// Check candidates in the reverse order they would have been
// drawn. This will determine what candiate's silhouette pixel
// would have been drawn at the point.
for (let d = candidateIDs.length - 1; d >= 0; d--) {
const id = candidateIDs[d];
const drawable = this._allDrawables[id];
if (drawable.isTouching(worldPos)) {
hits[id] = (hits[id] || 0) + 1;
break;
}
}
}
}
// Bias toward selecting anything over nothing
hits[RenderConstants.ID_NONE] = 0;
let hit = RenderConstants.ID_NONE;
for (const hitID in hits) {
if (Object.prototype.hasOwnProperty.call(hits, hitID) && (hits[hitID] > hits[hit])) {
hit = hitID;
}
}
return Number(hit);
}
/**
* @typedef DrawableExtraction
* @property {ImageData} data Raw pixel data for the drawable
* @property {number} x The x coordinate of the drawable's bounding box's top-left corner, in 'CSS pixels'
* @property {number} y The y coordinate of the drawable's bounding box's top-left corner, in 'CSS pixels'
* @property {number} width The drawable's bounding box width, in 'CSS pixels'
* @property {number} height The drawable's bounding box height, in 'CSS pixels'
*/
/**
* Return a drawable's pixel data and bounds in screen space.
* @param {int} drawableID The ID of the drawable to get pixel data for
* @return {DrawableExtraction} Data about the picked drawable
*/
extractDrawableScreenSpace (drawableID) {
const drawable = this._allDrawables[drawableID];
if (!drawable) throw new Error(`Could not extract drawable with ID ${drawableID}; it does not exist`);
this._doExitDrawRegion();
const nativeCenterX = this._nativeSize[0] * 0.5;
const nativeCenterY = this._nativeSize[1] * 0.5;
const scratchBounds = drawable.getFastBounds();
const canvas = this.canvas;
// Ratio of the screen-space scale of the stage's canvas to the "native size" of the stage
const scaleFactor = canvas.width / this._nativeSize[0];
// Bounds of the extracted drawable, in "canvas pixel space"
// (origin is 0, 0, destination is the canvas width, height).
const canvasSpaceBounds = new Rectangle();
canvasSpaceBounds.initFromBounds(
(scratchBounds.left + nativeCenterX) * scaleFactor,
(scratchBounds.right + nativeCenterX) * scaleFactor,
// in "canvas space", +y is down, but Rectangle methods assume bottom < top, so swap them
(nativeCenterY - scratchBounds.top) * scaleFactor,
(nativeCenterY - scratchBounds.bottom) * scaleFactor
);
canvasSpaceBounds.snapToInt();
// undo the transformation to transform the bounds, snapped to "canvas-pixel space", back to "Scratch space"
// We have to transform -> snap -> invert transform so that the "Scratch-space" bounds are snapped in
// "canvas-pixel space".
scratchBounds.initFromBounds(
(canvasSpaceBounds.left / scaleFactor) - nativeCenterX,
(canvasSpaceBounds.right / scaleFactor) - nativeCenterX,
nativeCenterY - (canvasSpaceBounds.top / scaleFactor),
nativeCenterY - (canvasSpaceBounds.bottom / scaleFactor)
);
const gl = this._gl;
// Set a reasonable max limit width and height for the bufferInfo bounds
const maxTextureSize = gl.getParameter(gl.MAX_TEXTURE_SIZE);
const clampedWidth = Math.min(MAX_EXTRACTED_DRAWABLE_DIMENSION, canvasSpaceBounds.width, maxTextureSize);
const clampedHeight = Math.min(MAX_EXTRACTED_DRAWABLE_DIMENSION, canvasSpaceBounds.height, maxTextureSize);
// Make a new bufferInfo since this._queryBufferInfo is limited to 480x360
const bufferInfo = twgl.createFramebufferInfo(gl, [{format: gl.RGBA}], clampedWidth, clampedHeight);
try {
twgl.bindFramebufferInfo(gl, bufferInfo);
// Limit size of viewport to the bounds around the target Drawable,
// and create the projection matrix for the draw.
gl.viewport(0, 0, clampedWidth, clampedHeight);
const projection = twgl.m4.ortho(
scratchBounds.left,
scratchBounds.right,
scratchBounds.top,
scratchBounds.bottom,
-1, 1
);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
this._drawThese([drawableID], ShaderManager.DRAW_MODE.straightAlpha, projection,
{
// Don't apply the ghost effect. TODO: is this an intentional design decision?
effectMask: ~ShaderManager.EFFECT_INFO.ghost.mask,
// We're doing this in screen-space, so the framebuffer dimensions should be those of the canvas in
// screen-space. This is used to ensure SVG skins are rendered at the proper resolution.
framebufferWidth: canvas.width,