mirror of
https://github.com/scratchfoundation/scratch-render.git
synced 2025-07-16 17:22:21 -04:00
2029 lines
81 KiB
JavaScript
2029 lines
81 KiB
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,
|
|
framebufferHeight: canvas.height
|
|
});
|
|
|
|
const data = new Uint8Array(Math.floor(clampedWidth * clampedHeight * 4));
|
|
gl.readPixels(0, 0, clampedWidth, clampedHeight, gl.RGBA, gl.UNSIGNED_BYTE, data);
|
|
// readPixels can only read into a Uint8Array, but ImageData has to take a Uint8ClampedArray.
|
|
// We can share the same underlying buffer between them to avoid having to copy any data.
|
|
const imageData = new ImageData(new Uint8ClampedArray(data.buffer), clampedWidth, clampedHeight);
|
|
|
|
// On high-DPI devices, the canvas' width (in canvas pixels) will be larger than its width in CSS pixels.
|
|
// We want to return the CSS-space bounds,
|
|
// so take into account the ratio between the canvas' pixel dimensions and its layout dimensions.
|
|
// This is usually the same as 1 / window.devicePixelRatio, but if e.g. you zoom your browser window without
|
|
// the canvas resizing, then it'll differ.
|
|
const ratio = canvas.getBoundingClientRect().width / canvas.width;
|
|
|
|
return {
|
|
imageData,
|
|
x: canvasSpaceBounds.left * ratio,
|
|
y: canvasSpaceBounds.bottom * ratio,
|
|
width: canvasSpaceBounds.width * ratio,
|
|
height: canvasSpaceBounds.height * ratio
|
|
};
|
|
} finally {
|
|
gl.deleteFramebuffer(bufferInfo.framebuffer);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @typedef ColorExtraction
|
|
* @property {Uint8Array} data Raw pixel data for the drawable
|
|
* @property {int} width Drawable bounding box width
|
|
* @property {int} height Drawable bounding box height
|
|
* @property {object} color Color object with RGBA properties at picked location
|
|
*/
|
|
|
|
/**
|
|
* Return drawable pixel data and color at a given position
|
|
* @param {int} x The client x coordinate of the picking location.
|
|
* @param {int} y The client y coordinate of the picking location.
|
|
* @param {int} radius The client radius to extract pixels with.
|
|
* @return {?ColorExtraction} Data about the picked color
|
|
*/
|
|
extractColor (x, y, radius) {
|
|
this._doExitDrawRegion();
|
|
|
|
const scratchX = Math.round(this._nativeSize[0] * ((x / this._gl.canvas.clientWidth) - 0.5));
|
|
const scratchY = Math.round(-this._nativeSize[1] * ((y / this._gl.canvas.clientHeight) - 0.5));
|
|
|
|
const gl = this._gl;
|
|
twgl.bindFramebufferInfo(gl, this._queryBufferInfo);
|
|
|
|
const bounds = new Rectangle();
|
|
bounds.initFromBounds(scratchX - radius, scratchX + radius, scratchY - radius, scratchY + radius);
|
|
|
|
const pickX = scratchX - bounds.left;
|
|
const pickY = bounds.top - scratchY;
|
|
|
|
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(...this._backgroundColor4f);
|
|
gl.clear(gl.COLOR_BUFFER_BIT);
|
|
this._drawThese(this._drawList, ShaderManager.DRAW_MODE.default, projection);
|
|
|
|
const data = new Uint8Array(Math.floor(bounds.width * bounds.height * 4));
|
|
gl.readPixels(0, 0, bounds.width, bounds.height, gl.RGBA, gl.UNSIGNED_BYTE, data);
|
|
|
|
const pixelBase = Math.floor(4 * ((pickY * bounds.width) + pickX));
|
|
const color = {
|
|
r: data[pixelBase],
|
|
g: data[pixelBase + 1],
|
|
b: data[pixelBase + 2],
|
|
a: data[pixelBase + 3]
|
|
};
|
|
|
|
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.strokeStyle = 'black';
|
|
ctx.fillStyle = `rgba(${color.r}, ${color.g}, ${color.b}, ${color.a})`;
|
|
ctx.rect(pickX - 4, pickY - 4, 8, 8);
|
|
ctx.fill();
|
|
ctx.stroke();
|
|
}
|
|
|
|
return {
|
|
data: data,
|
|
width: bounds.width,
|
|
height: bounds.height,
|
|
color: color
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
* @return {?Array< {id, drawable, intersection} >} Filtered candidates with useful data.
|
|
*/
|
|
_candidatesTouching (drawableID, candidateIDs) {
|
|
const bounds = this._touchingBounds(drawableID);
|
|
const result = [];
|
|
if (bounds === null) {
|
|
return result;
|
|
}
|
|
// iterate through the drawables list BACKWARDS - we want the top most item to be the first we check
|
|
for (let index = candidateIDs.length - 1; index >= 0; index--) {
|
|
const id = candidateIDs[index];
|
|
if (id !== drawableID) {
|
|
const drawable = this._allDrawables[id];
|
|
// Text bubbles aren't considered in "touching" queries
|
|
if (drawable.skin instanceof TextBubbleSkin) continue;
|
|
if (drawable.skin && drawable._visible) {
|
|
// Update the CPU position data
|
|
drawable.updateCPURenderAttributes();
|
|
const candidateBounds = drawable.getFastBounds();
|
|
|
|
// Push bounds out to integers. If a drawable extends out into half a pixel, that half-pixel still
|
|
// needs to be tested. Plus, in some areas we construct another rectangle from the union of these,
|
|
// and iterate over its pixels (width * height). Turns out that doesn't work so well when the
|
|
// width/height aren't integers.
|
|
candidateBounds.snapToInt();
|
|
|
|
if (bounds.intersects(candidateBounds)) {
|
|
result.push({
|
|
id,
|
|
drawable,
|
|
intersection: Rectangle.intersect(bounds, candidateBounds)
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Helper to get the union bounds from a set of candidates returned from the above method
|
|
* @private
|
|
* @param {Array<object>} candidates info from _candidatesTouching
|
|
* @return {Rectangle} the outer bounding box union
|
|
*/
|
|
_candidatesBounds (candidates) {
|
|
return candidates.reduce((memo, {intersection}) => {
|
|
if (!memo) {
|
|
return intersection;
|
|
}
|
|
// store the union of the two rectangles in our static rectangle instance
|
|
return Rectangle.union(memo, intersection, __candidatesBounds);
|
|
}, null);
|
|
}
|
|
|
|
/**
|
|
* Update a drawable's skin.
|
|
* @param {number} drawableID The drawable's id.
|
|
* @param {number} skinId The skin to update to.
|
|
*/
|
|
updateDrawableSkinId (drawableID, skinId) {
|
|
const drawable = this._allDrawables[drawableID];
|
|
// TODO: https://github.com/LLK/scratch-vm/issues/2288
|
|
if (!drawable) return;
|
|
drawable.skin = this._allSkins[skinId];
|
|
}
|
|
|
|
/**
|
|
* Update a drawable's position.
|
|
* @param {number} drawableID The drawable's id.
|
|
* @param {Array.<number>} position The new position.
|
|
*/
|
|
updateDrawablePosition (drawableID, position) {
|
|
const drawable = this._allDrawables[drawableID];
|
|
// TODO: https://github.com/LLK/scratch-vm/issues/2288
|
|
if (!drawable) return;
|
|
drawable.updatePosition(position);
|
|
}
|
|
|
|
/**
|
|
* Update a drawable's direction.
|
|
* @param {number} drawableID The drawable's id.
|
|
* @param {number} direction A new direction.
|
|
*/
|
|
updateDrawableDirection (drawableID, direction) {
|
|
const drawable = this._allDrawables[drawableID];
|
|
// TODO: https://github.com/LLK/scratch-vm/issues/2288
|
|
if (!drawable) return;
|
|
drawable.updateDirection(direction);
|
|
}
|
|
|
|
/**
|
|
* Update a drawable's scale.
|
|
* @param {number} drawableID The drawable's id.
|
|
* @param {Array.<number>} scale A new scale.
|
|
*/
|
|
updateDrawableScale (drawableID, scale) {
|
|
const drawable = this._allDrawables[drawableID];
|
|
// TODO: https://github.com/LLK/scratch-vm/issues/2288
|
|
if (!drawable) return;
|
|
drawable.updateScale(scale);
|
|
}
|
|
|
|
/**
|
|
* Update a drawable's direction and scale together.
|
|
* @param {number} drawableID The drawable's id.
|
|
* @param {number} direction A new direction.
|
|
* @param {Array.<number>} scale A new scale.
|
|
*/
|
|
updateDrawableDirectionScale (drawableID, direction, scale) {
|
|
const drawable = this._allDrawables[drawableID];
|
|
// TODO: https://github.com/LLK/scratch-vm/issues/2288
|
|
if (!drawable) return;
|
|
drawable.updateDirection(direction);
|
|
drawable.updateScale(scale);
|
|
}
|
|
|
|
/**
|
|
* Update a drawable's visibility.
|
|
* @param {number} drawableID The drawable's id.
|
|
* @param {boolean} visible Will the drawable be visible?
|
|
*/
|
|
updateDrawableVisible (drawableID, visible) {
|
|
const drawable = this._allDrawables[drawableID];
|
|
// TODO: https://github.com/LLK/scratch-vm/issues/2288
|
|
if (!drawable) return;
|
|
drawable.updateVisible(visible);
|
|
}
|
|
|
|
/**
|
|
* Update a drawable's visual effect.
|
|
* @param {number} drawableID The drawable's id.
|
|
* @param {string} effectName The effect to change.
|
|
* @param {number} value A new effect value.
|
|
*/
|
|
updateDrawableEffect (drawableID, effectName, value) {
|
|
const drawable = this._allDrawables[drawableID];
|
|
// TODO: https://github.com/LLK/scratch-vm/issues/2288
|
|
if (!drawable) return;
|
|
drawable.updateEffect(effectName, value);
|
|
}
|
|
|
|
/**
|
|
* Update the position, direction, scale, or effect properties of this Drawable.
|
|
* @deprecated Use specific updateDrawable* methods instead.
|
|
* @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(https://github.com/LLK/scratch-vm/issues/2288) 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;
|
|
}
|
|
if ('skinId' in properties) {
|
|
this.updateDrawableSkinId(drawableID, properties.skinId);
|
|
}
|
|
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(https://github.com/LLK/scratch-vm/issues/2288) 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._skin.getFenceBounds(drawable, __fenceBounds);
|
|
const inset = Math.floor(Math.min(aabb.width, aabb.height) / 2);
|
|
|
|
const sx = this._xRight - Math.min(FENCE_WIDTH, inset);
|
|
if (aabb.right + dx < -sx) {
|
|
x = Math.ceil(drawable._position[0] - (sx + aabb.right));
|
|
} else if (aabb.left + dx > sx) {
|
|
x = Math.floor(drawable._position[0] + (sx - aabb.left));
|
|
}
|
|
const sy = this._yTop - Math.min(FENCE_WIDTH, inset);
|
|
if (aabb.top + dy < -sy) {
|
|
y = Math.ceil(drawable._position[1] - (sy + aabb.top));
|
|
} else if (aabb.bottom + dy > sy) {
|
|
y = Math.floor(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;
|
|
}
|
|
|
|
this._doExitDrawRegion();
|
|
|
|
const skin = /** @type {PenSkin} */ this._allSkins[penSkinID];
|
|
|
|
const gl = this._gl;
|
|
twgl.bindFramebufferInfo(gl, skin._framebuffer);
|
|
|
|
// Limit size of viewport to the bounds around the stamp Drawable and create the projection matrix for the draw.
|
|
gl.viewport(
|
|
(this._nativeSize[0] * 0.5) + bounds.left,
|
|
(this._nativeSize[1] * 0.5) - bounds.top,
|
|
bounds.width,
|
|
bounds.height
|
|
);
|
|
const projection = twgl.m4.ortho(bounds.left, bounds.right, bounds.top, bounds.bottom, -1, 1);
|
|
|
|
// Draw the stamped sprite onto the PenSkin's framebuffer.
|
|
this._drawThese([stampID], ShaderManager.DRAW_MODE.default, projection, {ignoreVisibility: true});
|
|
skin._silhouetteDirty = true;
|
|
}
|
|
|
|
/* ******
|
|
* 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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Enter a draw region.
|
|
*
|
|
* A draw region is where multiple draw operations are performed with the
|
|
* same GL state. WebGL performs poorly when it changes state like blend
|
|
* mode. Marking a collection of state values as a "region" the renderer
|
|
* can skip superfluous extra state calls when it is already in that
|
|
* region. Since one region may be entered from within another a exit
|
|
* handle can also be registered that is called when a new region is about
|
|
* to be entered to restore a common inbetween state.
|
|
*
|
|
* @param {any} regionId - id of the region to enter
|
|
* @param {function} enter - handle to call when first entering a region
|
|
* @param {function} exit - handle to call when leaving a region
|
|
*/
|
|
enterDrawRegion (regionId, enter = regionId.enter, exit = regionId.exit) {
|
|
if (this._regionId !== regionId) {
|
|
this._doExitDrawRegion();
|
|
this._regionId = regionId;
|
|
enter();
|
|
this._exitRegion = exit;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Forcefully exit the current region returning to a common inbetween GL
|
|
* state.
|
|
*/
|
|
_doExitDrawRegion () {
|
|
if (this._exitRegion !== null) {
|
|
this._exitRegion();
|
|
}
|
|
this._exitRegion = null;
|
|
this._regionId = null;
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
* @param {boolean} opts.ignoreVisibility Draw all, despite visibility (e.g. stamping, touching color)
|
|
* @param {int} opts.framebufferWidth The width of the framebuffer being drawn onto. Defaults to "native" width
|
|
* @param {int} opts.framebufferHeight The height of the framebuffer being drawn onto. Defaults to "native" height
|
|
* @private
|
|
*/
|
|
_drawThese (drawables, drawMode, projection, opts = {}) {
|
|
|
|
const gl = this._gl;
|
|
let currentShader = null;
|
|
|
|
const framebufferSpaceScaleDiffers = (
|
|
'framebufferWidth' in opts && 'framebufferHeight' in opts &&
|
|
opts.framebufferWidth !== this._nativeSize[0] && opts.framebufferHeight !== this._nativeSize[1]
|
|
);
|
|
|
|
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 not drawn unless
|
|
// the ignoreVisibility flag is used (e.g. for stamping or touchingColor).
|
|
if (!drawable.getVisible() && !opts.ignoreVisibility) continue;
|
|
|
|
// drawableScale is the "framebuffer-pixel-space" scale of the drawable, as percentages of the drawable's
|
|
// "native size" (so 100 = same as skin's "native size", 200 = twice "native size").
|
|
// If the framebuffer dimensions are the same as the stage's "native" size, there's no need to calculate it.
|
|
const drawableScale = framebufferSpaceScaleDiffers ? [
|
|
drawable.scale[0] * opts.framebufferWidth / this._nativeSize[0],
|
|
drawable.scale[1] * opts.framebufferHeight / this._nativeSize[1]
|
|
] : drawable.scale;
|
|
|
|
// If the skin or texture isn't ready yet, skip it.
|
|
if (!drawable.skin || !drawable.skin.getTexture(drawableScale)) continue;
|
|
|
|
const uniforms = {};
|
|
|
|
let effectBits = drawable.enabledEffects;
|
|
effectBits &= Object.prototype.hasOwnProperty.call(opts, 'effectMask') ? opts.effectMask : effectBits;
|
|
const newShader = this._shaderManager.getShader(drawMode, effectBits);
|
|
|
|
// Manually perform region check. Do not create functions inside a
|
|
// loop.
|
|
if (this._regionId !== newShader) {
|
|
this._doExitDrawRegion();
|
|
this._regionId = newShader;
|
|
|
|
currentShader = newShader;
|
|
gl.useProgram(currentShader.program);
|
|
twgl.setBuffersAndAttributes(gl, currentShader, this._bufferInfo);
|
|
Object.assign(uniforms, {
|
|
u_projectionMatrix: projection
|
|
});
|
|
}
|
|
|
|
Object.assign(uniforms,
|
|
drawable.skin.getUniforms(drawableScale),
|
|
drawable.getUniforms());
|
|
|
|
// Apply extra uniforms after the Drawable's, to allow overwriting.
|
|
if (opts.extraUniforms) {
|
|
Object.assign(uniforms, opts.extraUniforms);
|
|
}
|
|
|
|
if (uniforms.u_skin) {
|
|
twgl.setTextureParameters(
|
|
gl, uniforms.u_skin, {
|
|
minMag: drawable.skin.useNearest(drawableScale, drawable) ? gl.NEAREST : gl.LINEAR
|
|
}
|
|
);
|
|
}
|
|
|
|
twgl.setUniforms(currentShader, uniforms);
|
|
twgl.drawBufferInfo(gl, this._bufferInfo, gl.TRIANGLES);
|
|
}
|
|
|
|
this._regionId = null;
|
|
}
|
|
|
|
/**
|
|
* Get the convex hull points for a particular Drawable.
|
|
* To do this, calculate it based on the drawable's Silhouette.
|
|
* @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 [];
|
|
}
|
|
|
|
drawable.updateCPURenderAttributes();
|
|
|
|
/**
|
|
* Return the determinant of two vectors, the vector from A to B and the vector from A to C.
|
|
*
|
|
* The determinant is useful in this case to know if AC is counter-clockwise from AB.
|
|
* A positive value means that AC is counter-clockwise from AB. A negative value means AC is clockwise from AB.
|
|
*
|
|
* @param {Float32Array} A A 2d vector in space.
|
|
* @param {Float32Array} B A 2d vector in space.
|
|
* @param {Float32Array} C A 2d vector in space.
|
|
* @return {number} Greater than 0 if counter clockwise, less than if clockwise, 0 if all points are on a line.
|
|
*/
|
|
const determinant = function (A, B, C) {
|
|
// AB = B - A
|
|
// AC = C - A
|
|
// det (AB BC) = AB0 * AC1 - AB1 * AC0
|
|
return (((B[0] - A[0]) * (C[1] - A[1])) - ((B[1] - A[1]) * (C[0] - A[0])));
|
|
};
|
|
|
|
// This algorithm for calculating the convex hull somewhat resembles the monotone chain algorithm.
|
|
// The main difference is that instead of sorting the points by x-coordinate, and y-coordinate in case of ties,
|
|
// it goes through them by y-coordinate in the outer loop and x-coordinate in the inner loop.
|
|
// This gives us "left" and "right" hulls, whereas the monotone chain algorithm gives "top" and "bottom" hulls.
|
|
// Adapted from https://github.com/LLK/scratch-flash/blob/dcbeeb59d44c3be911545dfe54d46a32404f8e69/src/scratch/ScratchCostume.as#L369-L413
|
|
|
|
const leftHull = [];
|
|
const rightHull = [];
|
|
|
|
// While convex hull algorithms usually push and pop values from the list of hull points,
|
|
// here, we keep indices for the "last" point in each array. Any points past these indices are ignored.
|
|
// This is functionally equivalent to pushing and popping from a "stack" of hull points.
|
|
let leftEndPointIndex = -1;
|
|
let rightEndPointIndex = -1;
|
|
|
|
const _pixelPos = twgl.v3.create();
|
|
const _effectPos = twgl.v3.create();
|
|
|
|
let currentPoint;
|
|
|
|
// *Not* Scratch Space-- +y is bottom
|
|
// Loop over all rows of pixels, starting at the top
|
|
for (let y = 0; y < height; y++) {
|
|
_pixelPos[1] = y / height;
|
|
|
|
// We start at the leftmost point, then go rightwards until we hit an opaque pixel
|
|
let x = 0;
|
|
for (; x < width; x++) {
|
|
_pixelPos[0] = x / width;
|
|
EffectTransform.transformPoint(drawable, _pixelPos, _effectPos);
|
|
if (drawable.skin.isTouchingLinear(_effectPos)) {
|
|
currentPoint = [x, y];
|
|
break;
|
|
}
|
|
}
|
|
|
|
// If we managed to loop all the way through, there are no opaque pixels on this row. Go to the next one
|
|
if (x >= width) {
|
|
continue;
|
|
}
|
|
|
|
// Because leftEndPointIndex is initialized to -1, this is skipped for the first two rows.
|
|
// It runs only when there are enough points in the left hull to make at least one line.
|
|
// If appending the current point to the left hull makes a counter-clockwise turn,
|
|
// we want to append the current point. Otherwise, we decrement the index of the "last" hull point until the
|
|
// current point makes a counter-clockwise turn.
|
|
// This decrementing has the same effect as popping from the point list, but is hopefully faster.
|
|
while (leftEndPointIndex > 0) {
|
|
if (determinant(leftHull[leftEndPointIndex], leftHull[leftEndPointIndex - 1], currentPoint) > 0) {
|
|
break;
|
|
} else {
|
|
// leftHull.pop();
|
|
--leftEndPointIndex;
|
|
}
|
|
}
|
|
|
|
// This has the same effect as pushing to the point list.
|
|
// This "list head pointer" coding style leaves excess points dangling at the end of the list,
|
|
// but that doesn't matter; we simply won't copy them over to the final hull.
|
|
|
|
// leftHull.push(currentPoint);
|
|
leftHull[++leftEndPointIndex] = currentPoint;
|
|
|
|
// Now we repeat the process for the right side, looking leftwards for a pixel.
|
|
for (x = width - 1; x >= 0; x--) {
|
|
_pixelPos[0] = x / width;
|
|
EffectTransform.transformPoint(drawable, _pixelPos, _effectPos);
|
|
if (drawable.skin.isTouchingLinear(_effectPos)) {
|
|
currentPoint = [x, y];
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Because we're coming at this from the right, it goes clockwise this time.
|
|
while (rightEndPointIndex > 0) {
|
|
if (determinant(rightHull[rightEndPointIndex], rightHull[rightEndPointIndex - 1], currentPoint) < 0) {
|
|
break;
|
|
} else {
|
|
--rightEndPointIndex;
|
|
}
|
|
}
|
|
|
|
rightHull[++rightEndPointIndex] = currentPoint;
|
|
}
|
|
|
|
// Start off "hullPoints" with the left hull points.
|
|
const hullPoints = leftHull;
|
|
// This is where we get rid of those dangling extra points.
|
|
hullPoints.length = leftEndPointIndex + 1;
|
|
// Add points from the right side in reverse order so all points are ordered clockwise.
|
|
for (let j = rightEndPointIndex; j >= 0; --j) {
|
|
hullPoints.push(rightHull[j]);
|
|
}
|
|
|
|
// Simplify boundary points using hull.js.
|
|
// TODO: Remove this; this algorithm already generates convex hulls.
|
|
return hull(hullPoints, Infinity);
|
|
}
|
|
|
|
/**
|
|
* Sample a "final" color from an array of drawables at a given scratch space.
|
|
* Will blend any alpha values with the drawables "below" it.
|
|
* @param {twgl.v3} vec Scratch Vector Space to sample
|
|
* @param {Array<Drawables>} drawables A list of drawables with the "top most"
|
|
* drawable at index 0
|
|
* @param {Uint8ClampedArray} dst The color3b space to store the answer in.
|
|
* @return {Uint8ClampedArray} The dst vector with everything blended down.
|
|
*/
|
|
static sampleColor3b (vec, drawables, dst) {
|
|
dst = dst || new Uint8ClampedArray(3);
|
|
dst.fill(0);
|
|
let blendAlpha = 1;
|
|
for (let index = 0; blendAlpha !== 0 && index < drawables.length; index++) {
|
|
/*
|
|
if (left > vec[0] || right < vec[0] ||
|
|
bottom > vec[1] || top < vec[0]) {
|
|
continue;
|
|
}
|
|
*/
|
|
Drawable.sampleColor4b(vec, drawables[index].drawable, __blendColor);
|
|
// Equivalent to gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA)
|
|
dst[0] += __blendColor[0] * blendAlpha;
|
|
dst[1] += __blendColor[1] * blendAlpha;
|
|
dst[2] += __blendColor[2] * blendAlpha;
|
|
blendAlpha *= (1 - (__blendColor[3] / 255));
|
|
}
|
|
// Backdrop could be transparent, so we need to go to the "clear color" of the
|
|
// draw scene (white) as a fallback if everything was alpha
|
|
dst[0] += blendAlpha * 255;
|
|
dst[1] += blendAlpha * 255;
|
|
dst[2] += blendAlpha * 255;
|
|
return dst;
|
|
}
|
|
|
|
/**
|
|
* @callback RenderWebGL#snapshotCallback
|
|
* @param {string} dataURI Data URI of the snapshot of the renderer
|
|
*/
|
|
|
|
/**
|
|
* @param {snapshotCallback} callback Function called in the next frame with the snapshot data
|
|
*/
|
|
requestSnapshot (callback) {
|
|
this._snapshotCallbacks.push(callback);
|
|
}
|
|
}
|
|
|
|
// :3
|
|
RenderWebGL.prototype.canHazPixels = RenderWebGL.prototype.extractDrawableScreenSpace;
|
|
|
|
/**
|
|
* Values for setUseGPU()
|
|
* @enum {string}
|
|
*/
|
|
RenderWebGL.UseGpuModes = {
|
|
/**
|
|
* Heuristically decide whether to use the GPU path, the CPU path, or a dynamic mixture of the two.
|
|
*/
|
|
Automatic: 'Automatic',
|
|
|
|
/**
|
|
* Always use the GPU path.
|
|
*/
|
|
ForceGPU: 'ForceGPU',
|
|
|
|
/**
|
|
* Always use the CPU path.
|
|
*/
|
|
ForceCPU: 'ForceCPU'
|
|
};
|
|
|
|
module.exports = RenderWebGL;
|