Initial commit of 001code-html Scratch frontend project.

Includes scratch-gui, scratch-vm, scratch-blocks, scratch-render, scratch-l10n, and deployment config.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-16 15:37:45 +08:00
commit 6e0a1fbcbb
11350 changed files with 965674 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
module.exports = {
root: true,
extends: ['scratch', 'scratch/es6', 'scratch/node'],
env: {
node: false,
browser: true // TODO: disable this
},
globals: {
Buffer: true // TODO: remove this?
}
};

View File

@@ -0,0 +1,116 @@
const twgl = require('twgl.js');
const Skin = require('./Skin');
class BitmapSkin extends Skin {
/**
* Create a new Bitmap Skin.
* @extends Skin
* @param {!int} id - The ID for this Skin.
* @param {!RenderWebGL} renderer - The renderer which will use this skin.
*/
constructor (id, renderer) {
super(id, renderer);
/** @type {!int} */
this._costumeResolution = 1;
/** @type {Array<int>} */
this._textureSize = [0, 0];
}
/**
* Dispose of this object. Do not use it after calling this method.
*/
dispose () {
if (this._texture) {
this._renderer.gl.deleteTexture(this._texture);
this._texture = null;
}
super.dispose();
}
/**
* @return {Array<number>} the "native" size, in texels, of this skin.
*/
get size () {
return [this._textureSize[0] / this._costumeResolution, this._textureSize[1] / this._costumeResolution];
}
/**
* @param {Array<number>} scale - The scaling factors to be used.
* @return {WebGLTexture} The GL texture representation of this skin when drawing at the given scale.
*/
// eslint-disable-next-line no-unused-vars
getTexture (scale) {
return this._texture || super.getTexture();
}
/**
* Set the contents of this skin to 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 for the bitmap. If not supplied, it will be
* calculated from the bounding box
* @fires Skin.event:WasAltered
*/
setBitmap (bitmapData, costumeResolution, rotationCenter) {
if (!bitmapData.width || !bitmapData.height) {
super.setEmptyImageData();
return;
}
const gl = this._renderer.gl;
// TW: We want to use <canvas> as-is because reading ImageData wastes memory.
// However, vanilla LLK/scratch-vm will reuse any canvas that we get here for other costumes,
// which will cause bugs when Silhouette lazily reads the canvas data.
// TurboWarp/scratch-vm does not reuse canvases and will set canvas.reusable = false.
let textureData = bitmapData;
if (bitmapData instanceof HTMLCanvasElement && bitmapData.reusable !== false) {
const context = bitmapData.getContext('2d');
textureData = context.getImageData(0, 0, bitmapData.width, bitmapData.height);
}
if (this._texture === null) {
const textureOptions = {
auto: false,
wrap: gl.CLAMP_TO_EDGE
};
this._texture = twgl.createTexture(gl, textureOptions);
}
this._setTexture(textureData);
// Do these last in case any of the above throws an exception
this._costumeResolution = costumeResolution || 2;
this._textureSize = BitmapSkin._getBitmapSize(bitmapData);
if (typeof rotationCenter === 'undefined') rotationCenter = this.calculateRotationCenter();
this._rotationCenter[0] = rotationCenter[0];
this._rotationCenter[1] = rotationCenter[1];
this.emitWasAltered();
}
/**
* @param {ImageData|HTMLImageElement|HTMLCanvasElement|HTMLVideoElement} bitmapData - bitmap data to inspect.
* @returns {Array<int>} the width and height of the bitmap data, in pixels.
* @private
*/
static _getBitmapSize (bitmapData) {
if (bitmapData instanceof HTMLImageElement) {
return [bitmapData.naturalWidth || bitmapData.width, bitmapData.naturalHeight || bitmapData.height];
}
if (bitmapData instanceof HTMLVideoElement) {
return [bitmapData.videoWidth || bitmapData.width, bitmapData.videoHeight || bitmapData.height];
}
// ImageData or HTMLCanvasElement
return [bitmapData.width, bitmapData.height];
}
}
module.exports = BitmapSkin;

View File

@@ -0,0 +1,745 @@
const twgl = require('twgl.js');
const Rectangle = require('./Rectangle');
const RenderConstants = require('./RenderConstants');
const ShaderManager = require('./ShaderManager');
const EffectTransform = require('./EffectTransform');
const log = require('./util/log');
/**
* An internal workspace for calculating texture locations from world vectors
* this is REUSED for memory conservation reasons
* @type {twgl.v3}
*/
const __isTouchingPosition = twgl.v3.create();
const FLOATING_POINT_ERROR_ALLOWANCE = 1e-6;
/**
* Convert a scratch space location into a texture space float. Uses the
* internal __isTouchingPosition as a return value, so this should be copied
* if you ever need to get two local positions and store both. Requires that
* the drawable inverseMatrix is up to date.
*
* @param {Drawable} drawable The drawable to get the inverse matrix and uniforms from
* @param {twgl.v3} vec [x,y] scratch space vector
* @return {twgl.v3} [x,y] texture space float vector - transformed by effects and matrix
*/
const getLocalPosition = (drawable, vec) => {
// Transfrom from world coordinates to Drawable coordinates.
const localPosition = __isTouchingPosition;
const v0 = vec[0];
const v1 = vec[1];
const m = drawable._inverseMatrix;
// var v2 = v[2];
const d = (v0 * m[3]) + (v1 * m[7]) + m[15];
// The RenderWebGL quad flips the texture's X axis. So rendered bottom
// left is 1, 0 and the top right is 0, 1. Flip the X axis so
// localPosition matches that transformation.
localPosition[0] = 0.5 - (((v0 * m[0]) + (v1 * m[4]) + m[12]) / d);
localPosition[1] = (((v0 * m[1]) + (v1 * m[5]) + m[13]) / d) + 0.5;
// Fix floating point issues near 0. Filed https://github.com/LLK/scratch-render/issues/688 that
// they're happening in the first place.
// TODO: Check if this can be removed after render pull 479 is merged
if (Math.abs(localPosition[0]) < FLOATING_POINT_ERROR_ALLOWANCE) localPosition[0] = 0;
if (Math.abs(localPosition[1]) < FLOATING_POINT_ERROR_ALLOWANCE) localPosition[1] = 0;
// Apply texture effect transform if the localPosition is within the drawable's space,
// and any effects are currently active.
if (drawable.enabledEffects !== 0 &&
(localPosition[0] >= 0 && localPosition[0] < 1) &&
(localPosition[1] >= 0 && localPosition[1] < 1)) {
EffectTransform.transformPoint(drawable, localPosition, localPosition);
}
return localPosition;
};
class Drawable {
/**
* An object which can be drawn by the renderer.
* @todo double-buffer all rendering state (position, skin, effects, etc.)
* @param {!int} id - This Drawable's unique ID.
* @param {!RenderWebGL} renderer - The renderer that created this Drawable
* @constructor
*/
constructor (id, renderer) {
/** @type {!int} */
this._id = id;
this._renderer = renderer;
/**
* The uniforms to be used by the vertex and pixel shaders.
* Some of these are used by other parts of the renderer as well.
* @type {Object.<string,*>}
* @private
*/
this._uniforms = {
/**
* The model matrix, to concat with projection at draw time.
* @type {module:twgl/m4.Mat4}
*/
u_modelMatrix: twgl.m4.identity(),
/**
* The color to use in the silhouette draw mode.
* @type {Array<number>}
*/
u_silhouetteColor: Drawable.color4fFromID(this._id)
};
// Effect values are uniforms too
const numEffects = ShaderManager.EFFECTS.length;
for (let index = 0; index < numEffects; ++index) {
const effectName = ShaderManager.EFFECTS[index];
const effectInfo = ShaderManager.EFFECT_INFO[effectName];
const converter = effectInfo.converter;
this._uniforms[effectInfo.uniformName] = converter(0);
}
this._position = twgl.v3.create(0, 0);
this._scale = twgl.v3.create(100, 100);
this._direction = 90;
this._transformDirty = true;
this._rotationMatrix = twgl.m4.identity();
this._rotationTransformDirty = true;
this._rotationAdjusted = twgl.v3.create();
this._rotationCenterDirty = true;
this._skinScale = twgl.v3.create(0, 0, 0);
this._skinScaleDirty = true;
this._inverseMatrix = twgl.m4.identity();
this._inverseTransformDirty = true;
this._visible = true;
/** A bitmask identifying which effects are currently in use.
* @readonly
* @type {int} */
this.enabledEffects = 0;
/** @todo move convex hull functionality, maybe bounds functionality overall, to Skin classes */
this._convexHullPoints = null;
this._convexHullDirty = true;
// The precise bounding box will be from the transformed convex hull points,
// so initialize the array of transformed hull points in setConvexHullPoints.
// Initializing it once per convex hull recalculation avoids unnecessary creation of twgl.v3 objects.
this._transformedHullPoints = null;
this._transformedHullDirty = true;
this._skinWasAltered = this._skinWasAltered.bind(this);
this.isTouching = this._isTouchingNever;
this._highQuality = false;
}
setHighQuality (highQuality) {
this._highQuality = highQuality;
}
/**
* Dispose of this Drawable. Do not use it after calling this method.
*/
dispose () {
// Use the setter: disconnect events
this.skin = null;
}
/**
* Mark this Drawable's transform as dirty.
* It will be recalculated next time it's needed.
*/
setTransformDirty () {
this._transformDirty = true;
this._inverseTransformDirty = true;
this._transformedHullDirty = true;
}
/**
* @returns {number} The ID for this Drawable.
*/
get id () {
return this._id;
}
/**
* @returns {Skin} the current skin for this Drawable.
*/
get skin () {
return this._skin;
}
/**
* @param {Skin} newSkin - A new Skin for this Drawable.
*/
set skin (newSkin) {
if (this._skin !== newSkin) {
this._skin = newSkin;
this._skinWasAltered();
}
}
/**
* @returns {Array<number>} the current scaling percentages applied to this Drawable. [100,100] is normal size.
*/
get scale () {
return [this._scale[0], this._scale[1]];
}
/**
* @returns {object.<string, *>} the shader uniforms to be used when rendering this Drawable.
*/
getUniforms () {
if (this._transformDirty) {
this._calculateTransform();
}
return this._uniforms;
}
/**
* @returns {boolean} whether this Drawable is visible.
*/
getVisible () {
return this._visible;
}
/**
* Update the position if it is different. Marks the transform as dirty.
* @param {Array.<number>} position A new position.
*/
updatePosition (position) {
if (this._position[0] !== position[0] ||
this._position[1] !== position[1]) {
if (this._highQuality) {
this._position[0] = position[0];
this._position[1] = position[1];
} else {
this._position[0] = Math.round(position[0]);
this._position[1] = Math.round(position[1]);
}
this._renderer.dirty = true;
this.setTransformDirty();
}
}
/**
* Update the direction if it is different. Marks the transform as dirty.
* @param {number} direction A new direction.
*/
updateDirection (direction) {
if (this._direction !== direction) {
this._direction = direction;
this._renderer.dirty = true;
this._rotationTransformDirty = true;
this.setTransformDirty();
}
}
/**
* Update the scale if it is different. Marks the transform as dirty.
* @param {Array.<number>} scale A new scale.
*/
updateScale (scale) {
if (this._scale[0] !== scale[0] ||
this._scale[1] !== scale[1]) {
this._scale[0] = scale[0];
this._scale[1] = scale[1];
this._renderer.dirty = true;
this._rotationCenterDirty = true;
this._skinScaleDirty = true;
this.setTransformDirty();
}
}
/**
* Update visibility if it is different. Marks the convex hull as dirty.
* @param {boolean} visible A new visibility state.
*/
updateVisible (visible) {
if (this._visible !== visible) {
this._visible = visible;
this._renderer.dirty = true;
this.setConvexHullDirty();
}
}
/**
* Update an effect. Marks the convex hull as dirty if the effect changes shape.
* @param {string} effectName The name of the effect.
* @param {number} rawValue A new effect value.
*/
updateEffect (effectName, rawValue) {
this._renderer.dirty = true;
const effectInfo = ShaderManager.EFFECT_INFO[effectName];
if (rawValue) {
this.enabledEffects |= effectInfo.mask;
} else {
this.enabledEffects &= ~effectInfo.mask;
}
const converter = effectInfo.converter;
this._uniforms[effectInfo.uniformName] = converter(rawValue);
if (effectInfo.shapeChanges) {
this.setConvexHullDirty();
}
}
/**
* Update the position, direction, scale, or effect properties of this Drawable.
* @deprecated Use specific update* methods instead.
* @param {object.<string,*>} properties The new property values to set.
*/
updateProperties (properties) {
if ('position' in properties) {
this.updatePosition(properties.position);
}
if ('direction' in properties) {
this.updateDirection(properties.direction);
}
if ('scale' in properties) {
this.updateScale(properties.scale);
}
if ('visible' in properties) {
this.updateVisible(properties.visible);
}
const numEffects = ShaderManager.EFFECTS.length;
for (let index = 0; index < numEffects; ++index) {
const effectName = ShaderManager.EFFECTS[index];
if (effectName in properties) {
this.updateEffect(effectName, properties[effectName]);
}
}
}
/**
* Calculate the transform to use when rendering this Drawable.
* @private
*/
_calculateTransform () {
if (this._rotationTransformDirty) {
const rotation = (270 - this._direction) * Math.PI / 180;
// Calling rotationZ sets the destination matrix to a rotation
// around the Z axis setting matrix components 0, 1, 4 and 5 with
// cosine and sine values of the rotation.
// twgl.m4.rotationZ(rotation, this._rotationMatrix);
// twgl assumes the last value set to the matrix was anything.
// Drawable knows, it was another rotationZ matrix, so we can skip
// assigning the values that will never change.
const c = Math.cos(rotation);
const s = Math.sin(rotation);
this._rotationMatrix[0] = c;
this._rotationMatrix[1] = s;
// this._rotationMatrix[2] = 0;
// this._rotationMatrix[3] = 0;
this._rotationMatrix[4] = -s;
this._rotationMatrix[5] = c;
// this._rotationMatrix[6] = 0;
// this._rotationMatrix[7] = 0;
// this._rotationMatrix[8] = 0;
// this._rotationMatrix[9] = 0;
// this._rotationMatrix[10] = 1;
// this._rotationMatrix[11] = 0;
// this._rotationMatrix[12] = 0;
// this._rotationMatrix[13] = 0;
// this._rotationMatrix[14] = 0;
// this._rotationMatrix[15] = 1;
this._rotationTransformDirty = false;
}
// Adjust rotation center relative to the skin.
if (this._rotationCenterDirty && this.skin !== null) {
// twgl version of the following in function work.
// let rotationAdjusted = twgl.v3.subtract(
// this.skin.rotationCenter,
// twgl.v3.divScalar(this.skin.size, 2, this._rotationAdjusted),
// this._rotationAdjusted
// );
// rotationAdjusted = twgl.v3.multiply(
// rotationAdjusted, this._scale, rotationAdjusted
// );
// rotationAdjusted = twgl.v3.divScalar(
// rotationAdjusted, 100, rotationAdjusted
// );
// rotationAdjusted[1] *= -1; // Y flipped to Scratch coordinate.
// rotationAdjusted[2] = 0; // Z coordinate is 0.
// Locally assign rotationCenter and skinSize to keep from having
// the Skin getter properties called twice while locally assigning
// their components for readability.
const rotationCenter = this.skin.rotationCenter;
const skinSize = this.skin.size;
const center0 = rotationCenter[0];
const center1 = rotationCenter[1];
const skinSize0 = skinSize[0];
const skinSize1 = skinSize[1];
const scale0 = this._scale[0];
const scale1 = this._scale[1];
const rotationAdjusted = this._rotationAdjusted;
rotationAdjusted[0] = (center0 - (skinSize0 / 2)) * scale0 / 100;
rotationAdjusted[1] = ((center1 - (skinSize1 / 2)) * scale1 / 100) * -1;
// rotationAdjusted[2] = 0;
this._rotationCenterDirty = false;
}
if (this._skinScaleDirty && this.skin !== null) {
// twgl version of the following in function work.
// const scaledSize = twgl.v3.divScalar(
// twgl.v3.multiply(this.skin.size, this._scale),
// 100
// );
// // was NaN because the vectors have only 2 components.
// scaledSize[2] = 0;
// Locally assign skinSize to keep from having the Skin getter
// properties called twice.
const skinSize = this.skin.size;
const scaledSize = this._skinScale;
scaledSize[0] = skinSize[0] * this._scale[0] / 100;
scaledSize[1] = skinSize[1] * this._scale[1] / 100;
// scaledSize[2] = 0;
this._skinScaleDirty = false;
}
const modelMatrix = this._uniforms.u_modelMatrix;
// twgl version of the following in function work.
// twgl.m4.identity(modelMatrix);
// twgl.m4.translate(modelMatrix, this._position, modelMatrix);
// twgl.m4.multiply(modelMatrix, this._rotationMatrix, modelMatrix);
// twgl.m4.translate(modelMatrix, this._rotationAdjusted, modelMatrix);
// twgl.m4.scale(modelMatrix, scaledSize, modelMatrix);
// Drawable configures a 3D matrix for drawing in WebGL, but most values
// will never be set because the inputs are on the X and Y position axis
// and the Z rotation axis. Drawable can bring the work inside
// _calculateTransform and greatly reduce the ammount of math and array
// assignments needed.
const scale0 = this._skinScale[0];
const scale1 = this._skinScale[1];
const rotation00 = this._rotationMatrix[0];
const rotation01 = this._rotationMatrix[1];
const rotation10 = this._rotationMatrix[4];
const rotation11 = this._rotationMatrix[5];
const adjusted0 = this._rotationAdjusted[0];
const adjusted1 = this._rotationAdjusted[1];
const position0 = this._position[0];
const position1 = this._position[1];
// Commented assignments show what the values are when the matrix was
// instantiated. Those values will never change so they do not need to
// be reassigned.
modelMatrix[0] = scale0 * rotation00;
modelMatrix[1] = scale0 * rotation01;
// modelMatrix[2] = 0;
// modelMatrix[3] = 0;
modelMatrix[4] = scale1 * rotation10;
modelMatrix[5] = scale1 * rotation11;
// modelMatrix[6] = 0;
// modelMatrix[7] = 0;
// modelMatrix[8] = 0;
// modelMatrix[9] = 0;
// modelMatrix[10] = 1;
// modelMatrix[11] = 0;
modelMatrix[12] = (rotation00 * adjusted0) + (rotation10 * adjusted1) + position0;
modelMatrix[13] = (rotation01 * adjusted0) + (rotation11 * adjusted1) + position1;
// modelMatrix[14] = 0;
// modelMatrix[15] = 1;
this._transformDirty = false;
}
/**
* Whether the Drawable needs convex hull points provided by the renderer.
* @return {boolean} True when no convex hull known, or it's dirty.
*/
needsConvexHullPoints () {
return !this._convexHullPoints || this._convexHullDirty || this._convexHullPoints.length === 0;
}
/**
* Set the convex hull to be dirty.
* Do this whenever the Drawable's shape has possibly changed.
*/
setConvexHullDirty () {
this._convexHullDirty = true;
}
/**
* Set the convex hull points for the Drawable.
* @param {Array<Array<number>>} points Convex hull points, as [[x, y], ...]
*/
setConvexHullPoints (points) {
this._convexHullPoints = points;
this._convexHullDirty = false;
// Re-create the "transformed hull points" array.
// We only do this when the hull points change to avoid unnecessary allocations and GC.
this._transformedHullPoints = [];
for (let i = 0; i < points.length; i++) {
this._transformedHullPoints.push(twgl.v3.create());
}
this._transformedHullDirty = true;
}
/**
* @function
* @name isTouching
* Check if the world position touches the skin.
* The caller is responsible for ensuring this drawable's inverse matrix & its skin's silhouette are up-to-date.
* @see updateCPURenderAttributes
* @param {twgl.v3} vec World coordinate vector.
* @return {boolean} True if the world position touches the skin.
*/
// `updateCPURenderAttributes` sets this Drawable instance's `isTouching` method
// to one of the following three functions:
// If this drawable has no skin, set it to `_isTouchingNever`.
// Otherwise, if this drawable uses nearest-neighbor scaling at its current scale, set it to `_isTouchingNearest`.
// Otherwise, set it to `_isTouchingLinear`.
// This allows several checks to be moved from the `isTouching` function to `updateCPURenderAttributes`.
// eslint-disable-next-line no-unused-vars
_isTouchingNever (vec) {
return false;
}
_isTouchingNearest (vec) {
return this.skin.isTouchingNearest(getLocalPosition(this, vec));
}
_isTouchingLinear (vec) {
return this.skin.isTouchingLinear(getLocalPosition(this, vec));
}
/**
* Get the precise bounds for a Drawable.
* This function applies the transform matrix to the known convex hull,
* and then finds the minimum box along the axes.
* Before calling this, ensure the renderer has updated convex hull points.
* @param {?Rectangle} result optional destination for bounds calculation
* @return {!Rectangle} Bounds for a tight box around the Drawable.
*/
getBounds (result) {
if (this.needsConvexHullPoints()) {
throw new Error('Needs updated convex hull points before bounds calculation.');
}
if (this._transformDirty) {
this._calculateTransform();
}
const transformedHullPoints = this._getTransformedHullPoints();
// Search through transformed points to generate box on axes.
result = result || new Rectangle();
result.initFromPointsAABB(transformedHullPoints);
return result;
}
/**
* Get the precise bounds for the upper 8px slice of the Drawable.
* Used for calculating where to position a text bubble.
* Before calling this, ensure the renderer has updated convex hull points.
* @param {?Rectangle} result optional destination for bounds calculation
* @return {!Rectangle} Bounds for a tight box around a slice of the Drawable.
*/
getBoundsForBubble (result) {
if (this.needsConvexHullPoints()) {
throw new Error('Needs updated convex hull points before bubble bounds calculation.');
}
if (this._transformDirty) {
this._calculateTransform();
}
const slice = 8; // px, how tall the top slice to measure should be.
const transformedHullPoints = this._getTransformedHullPoints();
const maxY = Math.max.apply(null, transformedHullPoints.map(p => p[1]));
const filteredHullPoints = transformedHullPoints.filter(p => p[1] > maxY - slice);
// Search through filtered points to generate box on axes.
result = result || new Rectangle();
result.initFromPointsAABB(filteredHullPoints);
return result;
}
/**
* Get the rough axis-aligned bounding box for the Drawable.
* Calculated by transforming the skin's bounds.
* Note that this is less precise than the box returned by `getBounds`,
* which is tightly snapped to account for a Drawable's transparent regions.
* `getAABB` returns a much less accurate bounding box, but will be much
* faster to calculate so may be desired for quick checks/optimizations.
* @param {?Rectangle} result optional destination for bounds calculation
* @return {!Rectangle} Rough axis-aligned bounding box for Drawable.
*/
getAABB (result) {
if (this._transformDirty) {
this._calculateTransform();
}
const tm = this._uniforms.u_modelMatrix;
result = result || new Rectangle();
result.initFromModelMatrix(tm);
return result;
}
/**
* Return the best Drawable bounds possible without performing graphics queries.
* I.e., returns the tight bounding box when the convex hull points are already
* known, but otherwise return the rough AABB of the Drawable.
* @param {?Rectangle} result optional destination for bounds calculation
* @return {!Rectangle} Bounds for the Drawable.
*/
getFastBounds (result) {
if (!this.needsConvexHullPoints()) {
return this.getBounds(result);
}
return this.getAABB(result);
}
/**
* Transform all the convex hull points by the current Drawable's
* transform. This allows us to skip recalculating the convex hull
* for many Drawable updates, including translation, rotation, scaling.
* @return {!Array.<!Array.number>} Array of glPoints which are Array<x, y>
* @private
*/
_getTransformedHullPoints () {
if (!this._transformedHullDirty) {
return this._transformedHullPoints;
}
const projection = twgl.m4.ortho(-1, 1, -1, 1, -1, 1);
const skinSize = this.skin.size;
const halfXPixel = 1 / skinSize[0] / 2;
const halfYPixel = 1 / skinSize[1] / 2;
const tm = twgl.m4.multiply(this._uniforms.u_modelMatrix, projection);
for (let i = 0; i < this._convexHullPoints.length; i++) {
const point = this._convexHullPoints[i];
const dstPoint = this._transformedHullPoints[i];
dstPoint[0] = 0.5 + (-point[0] / skinSize[0]) - halfXPixel;
dstPoint[1] = (point[1] / skinSize[1]) - 0.5 + halfYPixel;
twgl.m4.transformPoint(tm, dstPoint, dstPoint);
}
this._transformedHullDirty = false;
return this._transformedHullPoints;
}
/**
* Update the transform matrix and calculate it's inverse for collision
* and local texture position purposes.
*/
updateMatrix () {
if (this._transformDirty) {
this._calculateTransform();
}
// Get the inverse of the model matrix or update it.
if (this._inverseTransformDirty) {
const inverse = this._inverseMatrix;
twgl.m4.copy(this._uniforms.u_modelMatrix, inverse);
// The normal matrix uses a z scaling of 0 causing model[10] to be
// 0. Getting a 4x4 inverse is impossible without a scaling in x, y,
// and z.
inverse[10] = 1;
twgl.m4.inverse(inverse, inverse);
this._inverseTransformDirty = false;
}
}
/**
* Update everything necessary to render this drawable on the CPU.
*/
updateCPURenderAttributes () {
this.updateMatrix();
// CPU rendering always occurs at the "native" size, so no need to scale up this._scale
if (this.skin) {
this.skin.updateSilhouette(this._scale);
if (this.skin.useNearest(this._scale, this)) {
this.isTouching = this._isTouchingNearest;
} else {
this.isTouching = this._isTouchingLinear;
}
} else {
log.warn(`Could not find skin for drawable with id: ${this._id}`);
this.isTouching = this._isTouchingNever;
}
}
/**
* Respond to an internal change in the current Skin.
*/
_skinWasAltered () {
this._renderer.dirty = true;
this._rotationCenterDirty = true;
this._skinScaleDirty = true;
this.setConvexHullDirty();
this.setTransformDirty();
}
/**
* Calculate a color to represent the given ID number. At least one component of
* the resulting color will be non-zero if the ID is not RenderConstants.ID_NONE.
* @param {int} id The ID to convert.
* @returns {Array<number>} An array of [r,g,b,a], each component in the range [0,1].
*/
static color4fFromID (id) {
id -= RenderConstants.ID_NONE;
const r = ((id >> 0) & 255) / 255.0;
const g = ((id >> 8) & 255) / 255.0;
const b = ((id >> 16) & 255) / 255.0;
return [r, g, b, 1.0];
}
/**
* Calculate the ID number represented by the given color. If all components of
* the color are zero, the result will be RenderConstants.ID_NONE; otherwise the result
* will be a valid ID.
* @param {int} r The red value of the color, in the range [0,255].
* @param {int} g The green value of the color, in the range [0,255].
* @param {int} b The blue value of the color, in the range [0,255].
* @returns {int} The ID represented by that color.
*/
static color3bToID (r, g, b) {
let id;
id = (r & 255) << 0;
id |= (g & 255) << 8;
id |= (b & 255) << 16;
return id + RenderConstants.ID_NONE;
}
/**
* Sample a color from a drawable's texture.
* The caller is responsible for ensuring this drawable's inverse matrix & its skin's silhouette are up-to-date.
* @see updateCPURenderAttributes
* @param {twgl.v3} vec The scratch space [x,y] vector
* @param {Drawable} drawable The drawable to sample the texture from
* @param {Uint8ClampedArray} dst The "color4b" representation of the texture at point.
* @param {number} [effectMask] A bitmask for which effects to use. Optional.
* @returns {Uint8ClampedArray} The dst object filled with the color4b
*/
static sampleColor4b (vec, drawable, dst, effectMask) {
const localPosition = getLocalPosition(drawable, vec);
if (localPosition[0] < 0 || localPosition[1] < 0 ||
localPosition[0] > 1 || localPosition[1] > 1) {
dst[0] = 0;
dst[1] = 0;
dst[2] = 0;
dst[3] = 0;
return dst;
}
const textColor =
// commenting out to only use nearest for now
// drawable.skin.useNearest(drawable._scale, drawable) ?
drawable.skin._silhouette.colorAtNearest(localPosition, dst);
// : drawable.skin._silhouette.colorAtLinear(localPosition, dst);
if (drawable.enabledEffects === 0) return textColor;
return EffectTransform.transformColor(drawable, textColor, effectMask);
}
}
module.exports = Drawable;

View File

@@ -0,0 +1,197 @@
/**
* @fileoverview
* A utility to transform a texture coordinate to another texture coordinate
* representing how the shaders apply effects.
*/
const twgl = require('twgl.js');
const {rgbToHsv, hsvToRgb} = require('./util/color-conversions');
const ShaderManager = require('./ShaderManager');
/**
* A texture coordinate is between 0 and 1. 0.5 is the center position.
* @const {number}
*/
const CENTER_X = 0.5;
/**
* A texture coordinate is between 0 and 1. 0.5 is the center position.
* @const {number}
*/
const CENTER_Y = 0.5;
/**
* Reused memory location for storing an HSV color value.
* @type {Array<number>}
*/
const __hsv = [0, 0, 0];
class EffectTransform {
/**
* Transform a color in-place given the drawable's effect uniforms. Will apply
* Ghost and Color and Brightness effects.
* @param {Drawable} drawable The drawable to get uniforms from.
* @param {Uint8ClampedArray} inOutColor The color to transform.
* @param {number} [effectMask] A bitmask for which effects to use. Optional.
* @returns {Uint8ClampedArray} dst filled with the transformed color
*/
static transformColor (drawable, inOutColor, effectMask) {
// If the color is fully transparent, don't bother attempting any transformations.
if (inOutColor[3] === 0) {
return inOutColor;
}
let effects = drawable.enabledEffects;
if (typeof effectMask === 'number') effects &= effectMask;
const uniforms = drawable.getUniforms();
const enableColor = (effects & ShaderManager.EFFECT_INFO.color.mask) !== 0;
const enableBrightness = (effects & ShaderManager.EFFECT_INFO.brightness.mask) !== 0;
if (enableColor || enableBrightness) {
// gl_FragColor.rgb /= gl_FragColor.a + epsilon;
// Here, we're dividing by the (previously pre-multiplied) alpha to ensure HSV is properly calculated
// for partially transparent pixels.
// epsilon is present in the shader because dividing by 0 (fully transparent pixels) messes up calculations.
// We're doing this with a Uint8ClampedArray here, so dividing by 0 just gives 255. We're later multiplying
// by 0 again, so it won't affect results.
const alpha = inOutColor[3] / 255;
inOutColor[0] /= alpha;
inOutColor[1] /= alpha;
inOutColor[2] /= alpha;
if (enableColor) {
// vec3 hsv = convertRGB2HSV(gl_FragColor.xyz);
const hsv = rgbToHsv(inOutColor, __hsv);
// this code forces grayscale values to be slightly saturated
// so that some slight change of hue will be visible
// const float minLightness = 0.11 / 2.0;
const minV = 0.11 / 2.0;
// const float minSaturation = 0.09;
const minS = 0.09;
// if (hsv.z < minLightness) hsv = vec3(0.0, 1.0, minLightness);
if (hsv[2] < minV) {
hsv[0] = 0;
hsv[1] = 1;
hsv[2] = minV;
// else if (hsv.y < minSaturation) hsv = vec3(0.0, minSaturation, hsv.z);
} else if (hsv[1] < minS) {
hsv[0] = 0;
hsv[1] = minS;
}
// hsv.x = mod(hsv.x + u_color, 1.0);
// if (hsv.x < 0.0) hsv.x += 1.0;
hsv[0] = (uniforms.u_color + hsv[0] + 1);
// gl_FragColor.rgb = convertHSV2RGB(hsl);
hsvToRgb(hsv, inOutColor);
}
if (enableBrightness) {
const brightness = uniforms.u_brightness * 255;
// gl_FragColor.rgb = clamp(gl_FragColor.rgb + vec3(u_brightness), vec3(0), vec3(1));
// We don't need to clamp because the Uint8ClampedArray does that for us
inOutColor[0] += brightness;
inOutColor[1] += brightness;
inOutColor[2] += brightness;
}
// gl_FragColor.rgb *= gl_FragColor.a + epsilon;
// Now we're doing the reverse, premultiplying by the alpha once again.
inOutColor[0] *= alpha;
inOutColor[1] *= alpha;
inOutColor[2] *= alpha;
}
if ((effects & ShaderManager.EFFECT_INFO.ghost.mask) !== 0) {
// gl_FragColor *= u_ghost
inOutColor[0] *= uniforms.u_ghost;
inOutColor[1] *= uniforms.u_ghost;
inOutColor[2] *= uniforms.u_ghost;
inOutColor[3] *= uniforms.u_ghost;
}
return inOutColor;
}
/**
* Transform a texture coordinate to one that would be select after applying shader effects.
* @param {Drawable} drawable The drawable whose effects to emulate.
* @param {twgl.v3} vec The texture coordinate to transform.
* @param {twgl.v3} dst A place to store the output coordinate.
* @return {twgl.v3} dst - The coordinate after being transform by effects.
*/
static transformPoint (drawable, vec, dst) {
twgl.v3.copy(vec, dst);
const effects = drawable.enabledEffects;
const uniforms = drawable.getUniforms();
if ((effects & ShaderManager.EFFECT_INFO.mosaic.mask) !== 0) {
// texcoord0 = fract(u_mosaic * texcoord0);
dst[0] = uniforms.u_mosaic * dst[0] % 1;
dst[1] = uniforms.u_mosaic * dst[1] % 1;
}
if ((effects & ShaderManager.EFFECT_INFO.pixelate.mask) !== 0) {
const skinUniforms = drawable.skin.getUniforms();
// vec2 pixelTexelSize = u_skinSize / u_pixelate;
const texelX = skinUniforms.u_skinSize[0] / uniforms.u_pixelate;
const texelY = skinUniforms.u_skinSize[1] / uniforms.u_pixelate;
// texcoord0 = (floor(texcoord0 * pixelTexelSize) + kCenter) /
// pixelTexelSize;
dst[0] = (Math.floor(dst[0] * texelX) + CENTER_X) / texelX;
dst[1] = (Math.floor(dst[1] * texelY) + CENTER_Y) / texelY;
}
if ((effects & ShaderManager.EFFECT_INFO.whirl.mask) !== 0) {
// const float kRadius = 0.5;
const RADIUS = 0.5;
// vec2 offset = texcoord0 - kCenter;
const offsetX = dst[0] - CENTER_X;
const offsetY = dst[1] - CENTER_Y;
// float offsetMagnitude = length(offset);
const offsetMagnitude = Math.sqrt(Math.pow(offsetX, 2) + Math.pow(offsetY, 2));
// float whirlFactor = max(1.0 - (offsetMagnitude / kRadius), 0.0);
const whirlFactor = Math.max(1.0 - (offsetMagnitude / RADIUS), 0.0);
// float whirlActual = u_whirl * whirlFactor * whirlFactor;
const whirlActual = uniforms.u_whirl * whirlFactor * whirlFactor;
// float sinWhirl = sin(whirlActual);
const sinWhirl = Math.sin(whirlActual);
// float cosWhirl = cos(whirlActual);
const cosWhirl = Math.cos(whirlActual);
// mat2 rotationMatrix = mat2(
// cosWhirl, -sinWhirl,
// sinWhirl, cosWhirl
// );
const rot1 = cosWhirl;
const rot2 = -sinWhirl;
const rot3 = sinWhirl;
const rot4 = cosWhirl;
// texcoord0 = rotationMatrix * offset + kCenter;
dst[0] = (rot1 * offsetX) + (rot3 * offsetY) + CENTER_X;
dst[1] = (rot2 * offsetX) + (rot4 * offsetY) + CENTER_Y;
}
if ((effects & ShaderManager.EFFECT_INFO.fisheye.mask) !== 0) {
// vec2 vec = (texcoord0 - kCenter) / kCenter;
const vX = (dst[0] - CENTER_X) / CENTER_X;
const vY = (dst[1] - CENTER_Y) / CENTER_Y;
// float vecLength = length(vec);
const vLength = Math.sqrt((vX * vX) + (vY * vY));
// float r = pow(min(vecLength, 1.0), u_fisheye) * max(1.0, vecLength);
const r = Math.pow(Math.min(vLength, 1), uniforms.u_fisheye) * Math.max(1, vLength);
// vec2 unit = vec / vecLength;
const unitX = vX / vLength;
const unitY = vY / vLength;
// texcoord0 = kCenter + r * unit * kCenter;
dst[0] = CENTER_X + (r * unitX * CENTER_X);
dst[1] = CENTER_Y + (r * unitY * CENTER_Y);
}
return dst;
}
}
module.exports = EffectTransform;

View File

@@ -0,0 +1,565 @@
const twgl = require('twgl.js');
const RenderConstants = require('./RenderConstants');
const Skin = require('./Skin');
const ShaderManager = require('./ShaderManager');
/**
* Attributes to use when drawing with the pen
* @typedef {object} PenSkin#PenAttributes
* @property {number} [diameter] - The size (diameter) of the pen.
* @property {Array<number>} [color4f] - The pen color as an array of [r,g,b,a], each component in the range [0,1].
*/
/**
* The pen attributes to use when unspecified.
* @type {PenSkin#PenAttributes}
* @memberof PenSkin
* @private
* @const
*/
const DefaultPenAttributes = {
color4f: [0, 0, 1, 1],
diameter: 1
};
const PEN_ATTRIBUTE_BUFFER_SIZE = 163800;
const PEN_ATTRIBUTE_STRIDE = 10;
const PEN_ATTRIBUTE_STRIDE_BYTES = PEN_ATTRIBUTE_STRIDE * 4;
class PenSkin extends Skin {
/**
* Create a Skin which implements a Scratch pen layer.
* @param {int} id - The unique ID for this Skin.
* @param {RenderWebGL} renderer - The renderer which will use this Skin.
* @extends Skin
* @listens RenderWebGL#event:NativeSizeChanged
*/
constructor (id, renderer) {
super(id, renderer);
/** @type {Array<number>} */
this._size = null;
/** @type {WebGLFramebuffer} */
this._framebuffer = null;
/** @type {boolean} */
this._silhouetteDirty = false;
/** @type {Uint8Array} */
this._silhouettePixels = null;
/** @type {ImageData} */
this._silhouetteImageData = null;
/** @type {object} */
this._lineOnBufferDrawRegionId = {
enter: () => this._enterDrawLineOnBuffer(),
exit: () => this._exitDrawLineOnBuffer()
};
/** @type {object} */
this._usePenBufferDrawRegionId = {
enter: () => this._enterUsePenBuffer(),
exit: () => this._exitUsePenBuffer()
};
/** @type {WebGLRenderingContext} */
const gl = this._renderer.gl;
// tw: renderQuality attribute
this.renderQuality = 1;
// tw: keep track of native size
this._nativeSize = renderer.getNativeSize();
const NO_EFFECTS = 0;
/** @type {twgl.ProgramInfo} */
this._lineShader = this._renderer._shaderManager.getShader(ShaderManager.DRAW_MODE.line, NO_EFFECTS);
// Draw region used to preserve texture when resizing
this._drawTextureShader = this._renderer._shaderManager.getShader(ShaderManager.DRAW_MODE.default, NO_EFFECTS);
/** @type {object} */
this._drawTextureRegionId = {
enter: () => this._enterDrawTexture(),
exit: () => this._exitDrawTexture()
};
this.a_position_glbuffer = gl.createBuffer();
this.a_position_loc = gl.getAttribLocation(this._lineShader.program, 'a_position');
this.a_lineColor_loc = gl.getAttribLocation(this._lineShader.program, 'a_lineColor');
this.a_lineThicknessAndLength_loc = gl.getAttribLocation(this._lineShader.program, 'a_lineThicknessAndLength');
this.a_penPoints_loc = gl.getAttribLocation(this._lineShader.program, 'a_penPoints');
this.attribute_glbuffer = gl.createBuffer();
this.attribute_index = 0;
this.attribute_data = new Float32Array(PEN_ATTRIBUTE_BUFFER_SIZE);
gl.bindBuffer(gl.ARRAY_BUFFER, this.attribute_glbuffer);
gl.bufferData(gl.ARRAY_BUFFER, this.attribute_data.length * 4, gl.STREAM_DRAW);
if (gl.drawArraysInstanced) {
// WebGL2 has native instanced rendering
this.instancedRendering = true;
this.glDrawArraysInstanced = gl.drawArraysInstanced.bind(gl);
this.glVertexAttribDivisor = gl.vertexAttribDivisor.bind(gl);
} else {
// WebGL1 may have instanced rendering through the ANGLE_instanced_arrays extension
const instancedArraysExtension = gl.getExtension('ANGLE_instanced_arrays');
if (instancedArraysExtension) {
this.instancedRendering = true;
this.glDrawArraysInstanced = instancedArraysExtension.drawArraysInstancedANGLE.bind(
instancedArraysExtension
);
this.glVertexAttribDivisor = instancedArraysExtension.vertexAttribDivisorANGLE.bind(
instancedArraysExtension
);
} else {
// Inefficient but still supported
this.instancedRendering = false;
}
}
if (this.instancedRendering) {
gl.bindBuffer(gl.ARRAY_BUFFER, this.a_position_glbuffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
1, 0,
0, 0,
1, 1,
0, 1
]), gl.STATIC_DRAW);
} else {
const positionBuffer = new Float32Array(PEN_ATTRIBUTE_BUFFER_SIZE / PEN_ATTRIBUTE_STRIDE * 2);
for (let i = 0; i < positionBuffer.length; i += 12) {
positionBuffer[i + 0] = 1;
positionBuffer[i + 1] = 0;
positionBuffer[i + 2] = 0;
positionBuffer[i + 3] = 0;
positionBuffer[i + 4] = 1;
positionBuffer[i + 5] = 1;
positionBuffer[i + 6] = 1;
positionBuffer[i + 7] = 1;
positionBuffer[i + 8] = 0;
positionBuffer[i + 9] = 0;
positionBuffer[i + 10] = 0;
positionBuffer[i + 11] = 1;
}
gl.bindBuffer(gl.ARRAY_BUFFER, this.a_position_glbuffer);
gl.bufferData(gl.ARRAY_BUFFER, positionBuffer, gl.STATIC_DRAW);
}
this.onNativeSizeChanged = this.onNativeSizeChanged.bind(this);
this._renderer.on(RenderConstants.Events.NativeSizeChanged, this.onNativeSizeChanged);
this._setCanvasSize(renderer.getNativeSize());
}
/**
* Dispose of this object. Do not use it after calling this method.
*/
dispose () {
this._renderer.removeListener(RenderConstants.Events.NativeSizeChanged, this.onNativeSizeChanged);
this._renderer.gl.deleteTexture(this._texture);
this._texture = null;
super.dispose();
}
/**
* @return {Array<number>} the "native" size, in texels, of this skin. [width, height]
*/
get size () {
// tw: use native size for Drawable positioning logic
return this._nativeSize;
}
useNearest (scale) {
// Use nearest-neighbor interpolation when scaling up the pen skin-- this matches Scratch 2.0.
// When scaling it down, use linear interpolation to avoid giving pen lines a "dashed" appearance.
return Math.max(scale[0], scale[1]) >= 100;
}
/**
* @param {Array<number>} scale The X and Y scaling factors to be used, as percentages of this skin's "native" size.
* @return {WebGLTexture} The GL texture representation of this skin when drawing at the given size.
*/
// eslint-disable-next-line no-unused-vars
getTexture (scale) {
return this._texture;
}
/**
* Clear the pen layer.
*/
clear () {
this._renderer.enterDrawRegion(this._usePenBufferDrawRegionId);
/* Reset framebuffer to transparent black */
const gl = this._renderer.gl;
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
this._silhouetteDirty = true;
}
/**
* Draw a point on the pen layer.
* @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.
*/
drawPoint (penAttributes, x, y) {
this.drawLine(penAttributes, x, y, x, y);
}
/**
* Draw a line on the pen layer.
* @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.
*/
drawLine (penAttributes, x0, y0, x1, y1) {
// For compatibility with Scratch 2.0, offset pen lines of width 1 and 3 so they're pixel-aligned.
// See https://github.com/LLK/scratch-render/pull/314
const diameter = penAttributes.diameter || DefaultPenAttributes.diameter;
const offset = (diameter === 1 || diameter === 3) ? 0.5 : 0;
this._drawLineOnBuffer(
penAttributes,
x0 + offset, y0 + offset,
x1 + offset, y1 + offset
);
this._silhouetteDirty = true;
}
/**
* Prepare to draw lines in the _lineOnBufferDrawRegionId region.
*/
_enterDrawLineOnBuffer () {
const gl = this._renderer.gl;
twgl.bindFramebufferInfo(gl, this._framebuffer);
gl.viewport(0, 0, this._size[0], this._size[1]);
const currentShader = this._lineShader;
gl.useProgram(currentShader.program);
const uniforms = {
u_skin: this._texture,
u_stageSize: this._size
};
twgl.setUniforms(currentShader, uniforms);
gl.bindBuffer(gl.ARRAY_BUFFER, this.a_position_glbuffer);
gl.enableVertexAttribArray(this.a_position_loc);
gl.vertexAttribPointer(this.a_position_loc, 2, gl.FLOAT, false, 2 * 4, 0);
this.attribute_index = 0;
}
/**
* Return to a base state from _lineOnBufferDrawRegionId.
*/
_exitDrawLineOnBuffer () {
// tw: flush when exiting pen rendering
if (this.attribute_index) {
this._flushLines();
}
const gl = this._renderer.gl;
twgl.bindFramebufferInfo(gl, null);
}
/**
* Prepare to do things with this PenSkin's framebuffer
*/
_enterUsePenBuffer () {
twgl.bindFramebufferInfo(this._renderer.gl, this._framebuffer);
}
/**
* Return to a base state
*/
_exitUsePenBuffer () {
twgl.bindFramebufferInfo(this._renderer.gl, null);
}
// tw: draw region used to preserve texture when resizing
_enterDrawTexture () {
this._enterUsePenBuffer();
const gl = this._renderer.gl;
gl.viewport(0, 0, this._size[0], this._size[1]);
gl.useProgram(this._drawTextureShader.program);
twgl.setBuffersAndAttributes(gl, this._drawTextureShader, this._renderer._bufferInfo);
}
_exitDrawTexture () {
this._exitUsePenBuffer();
}
_drawPenTexture (texture) {
this._renderer.enterDrawRegion(this._drawTextureRegionId);
const gl = this._renderer.gl;
const width = this._size[0];
const height = this._size[1];
const uniforms = {
u_skin: texture,
u_projectionMatrix: twgl.m4.ortho(
width / 2,
width / -2,
height / -2,
height / 2,
-1,
1,
twgl.m4.identity()
),
u_modelMatrix: twgl.m4.scaling(twgl.v3.create(
width,
height,
0
), twgl.m4.identity())
};
twgl.setTextureParameters(gl, texture, {
// Always use NEAREST because this most closely matches Scratch behavior
minMag: gl.NEAREST
});
twgl.setUniforms(this._drawTextureShader, uniforms);
twgl.drawBufferInfo(gl, this._renderer._bufferInfo, gl.TRIANGLES);
}
/**
* Draw a line on the framebuffer.
* Note that the point coordinates are in the following coordinate space:
* +y is down, (0, 0) is the center, and the coords range from (-width / 2, -height / 2) to (height / 2, width / 2).
* @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.
*/
_drawLineOnBuffer (penAttributes, x0, y0, x1, y1) {
this._renderer.enterDrawRegion(this._lineOnBufferDrawRegionId);
const iters = this.instancedRendering ? 1 : 6;
// For some reason, looking up the size of a buffer through .length can be slow,
// so use a constant instead.
if (this.attribute_index + (PEN_ATTRIBUTE_STRIDE * iters) > PEN_ATTRIBUTE_BUFFER_SIZE) {
this._flushLines();
}
const penColor = penAttributes.color4f || DefaultPenAttributes.color4f;
// tw: apply renderQuality
x0 *= this.renderQuality;
y0 *= this.renderQuality;
x1 *= this.renderQuality;
y1 *= this.renderQuality;
// Fun fact: Doing this calculation in the shader has the potential to overflow the floating-point range.
// 'mediump' precision is only required to have a range up to 2^14 (16384), so any lines longer than 2^7 (128)
// can overflow that, because you're squaring the operands, and they could end up as "infinity".
// Even GLSL's `length` function won't save us here:
// https://asawicki.info/news_1596_watch_out_for_reduced_precision_normalizelength_in_opengl_es
const lineDiffX = x1 - x0;
const lineDiffY = y1 - y0;
const lineLength = Math.sqrt((lineDiffX * lineDiffX) + (lineDiffY * lineDiffY));
// tw: apply renderQuality
const lineThickness = (penAttributes.diameter || DefaultPenAttributes.diameter) * this.renderQuality;
for (let i = 0; i < iters; i++) {
// Pen color sent to the GPU is pre-multiplied by transparency
this.attribute_data[this.attribute_index] = penColor[0] * penColor[3];
this.attribute_index++;
this.attribute_data[this.attribute_index] = penColor[1] * penColor[3];
this.attribute_index++;
this.attribute_data[this.attribute_index] = penColor[2] * penColor[3];
this.attribute_index++;
this.attribute_data[this.attribute_index] = penColor[3];
this.attribute_index++;
this.attribute_data[this.attribute_index] = lineThickness;
this.attribute_index++;
this.attribute_data[this.attribute_index] = lineLength;
this.attribute_index++;
this.attribute_data[this.attribute_index] = x0;
this.attribute_index++;
this.attribute_data[this.attribute_index] = -y0;
this.attribute_index++;
this.attribute_data[this.attribute_index] = lineDiffX;
this.attribute_index++;
this.attribute_data[this.attribute_index] = -lineDiffY;
this.attribute_index++;
}
}
_flushLines () {
/** @type {WebGLRenderingContext} */
const gl = this._renderer.gl;
gl.bindBuffer(gl.ARRAY_BUFFER, this.attribute_glbuffer);
gl.bufferSubData(gl.ARRAY_BUFFER, 0, new Float32Array(this.attribute_data.buffer, 0, this.attribute_index));
gl.enableVertexAttribArray(this.a_lineColor_loc);
gl.vertexAttribPointer(
this.a_lineColor_loc,
4, gl.FLOAT, false,
PEN_ATTRIBUTE_STRIDE_BYTES, 0
);
gl.enableVertexAttribArray(this.a_lineThicknessAndLength_loc);
gl.vertexAttribPointer(
this.a_lineThicknessAndLength_loc,
2, gl.FLOAT, false,
PEN_ATTRIBUTE_STRIDE_BYTES, 4 * 4
);
gl.enableVertexAttribArray(this.a_penPoints_loc);
gl.vertexAttribPointer(
this.a_penPoints_loc,
4, gl.FLOAT, false,
PEN_ATTRIBUTE_STRIDE_BYTES, 6 * 4
);
if (this.instancedRendering) {
this.glVertexAttribDivisor(this.a_lineColor_loc, 1);
this.glVertexAttribDivisor(this.a_lineThicknessAndLength_loc, 1);
this.glVertexAttribDivisor(this.a_penPoints_loc, 1);
this.glDrawArraysInstanced(
gl.TRIANGLE_STRIP,
0, 4,
this.attribute_index / PEN_ATTRIBUTE_STRIDE
);
this.glVertexAttribDivisor(this.a_lineColor_loc, 0);
this.glVertexAttribDivisor(this.a_lineThicknessAndLength_loc, 0);
this.glVertexAttribDivisor(this.a_penPoints_loc, 0);
} else {
gl.drawArrays(gl.TRIANGLES, 0, this.attribute_index / PEN_ATTRIBUTE_STRIDE);
}
this.attribute_index = 0;
this._silhouetteDirty = true;
}
/**
* React to a change in the renderer's native size.
* @param {object} event - The change event.
*/
onNativeSizeChanged (event) {
// tw: keep track of native size
this._nativeSize = event.newSize;
this._setCanvasSize([
event.newSize[0] * this.renderQuality,
event.newSize[1] * this.renderQuality
]);
this.emitWasAltered();
}
/**
* Set the size of the pen canvas.
* @param {Array<int>} canvasSize - the new width and height for the canvas.
* @private
*/
_setCanvasSize (canvasSize) {
const [width, height] = canvasSize;
// tw: do not resize if new size === old size
if (this._size && this._size[0] === width && this._size[1] === height) {
return;
}
this._size = canvasSize;
// tw: use native size for Drawable positioning logic
this._rotationCenter[0] = this._nativeSize[0] / 2;
this._rotationCenter[1] = this._nativeSize[1] / 2;
const gl = this._renderer.gl;
// tw: store current texture to redraw it later
const oldTexture = this._texture;
this._texture = twgl.createTexture(
gl,
{
mag: gl.NEAREST,
min: gl.NEAREST,
wrap: gl.CLAMP_TO_EDGE,
width,
height
}
);
const attachments = [
{
format: gl.RGBA,
attachment: this._texture
}
];
if (this._framebuffer) {
// tw: resize framebuffer info doesn't work here, so always make a new framebuffer
// twgl.resizeFramebufferInfo(gl, this._framebuffer, attachments, width, height);
this._framebuffer = twgl.createFramebufferInfo(gl, attachments, width, height);
} else {
this._framebuffer = twgl.createFramebufferInfo(gl, attachments, width, height);
}
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
// tw: preserve old texture when resizing
if (oldTexture) {
this._drawPenTexture(oldTexture);
}
this._silhouettePixels = new Uint8Array(Math.floor(width * height * 4));
this._silhouetteImageData = new ImageData(width, height);
this._silhouetteDirty = true;
}
// tw: sets the "quality" of the pen skin
setRenderQuality (quality) {
if (this.renderQuality === quality) {
return;
}
this.renderQuality = quality;
this._setCanvasSize([Math.round(this._nativeSize[0] * quality), Math.round(this._nativeSize[1] * quality)]);
}
/**
* If there have been pen operations that have dirtied the canvas, update
* now before someone wants to use our silhouette.
*/
updateSilhouette () {
if (this._silhouetteDirty) {
this._renderer.enterDrawRegion(this._usePenBufferDrawRegionId);
// Sample the framebuffer's pixels into the silhouette instance
const gl = this._renderer.gl;
gl.readPixels(
0, 0,
this._size[0], this._size[1],
gl.RGBA, gl.UNSIGNED_BYTE, this._silhouettePixels
);
this._silhouetteImageData.data.set(this._silhouettePixels);
this._silhouette.update(this._silhouetteImageData, true /* isPremultiplied */);
this._silhouetteDirty = false;
}
}
}
module.exports = PenSkin;

View File

@@ -0,0 +1,196 @@
class Rectangle {
/**
* A utility for creating and comparing axis-aligned rectangles.
* Rectangles are always initialized to the "largest possible rectangle";
* use one of the init* methods below to set up a particular rectangle.
* @constructor
*/
constructor () {
this.left = -Infinity;
this.right = Infinity;
this.bottom = -Infinity;
this.top = Infinity;
}
/**
* Initialize a Rectangle from given Scratch-coordinate bounds.
* @param {number} left Left bound of the rectangle.
* @param {number} right Right bound of the rectangle.
* @param {number} bottom Bottom bound of the rectangle.
* @param {number} top Top bound of the rectangle.
*/
initFromBounds (left, right, bottom, top) {
this.left = left;
this.right = right;
this.bottom = bottom;
this.top = top;
}
/**
* Initialize a Rectangle to the minimum AABB around a set of points.
* @param {Array<Array<number>>} points Array of [x, y] points.
*/
initFromPointsAABB (points) {
this.left = Infinity;
this.right = -Infinity;
this.top = -Infinity;
this.bottom = Infinity;
for (let i = 0; i < points.length; i++) {
const x = points[i][0];
const y = points[i][1];
if (x < this.left) {
this.left = x;
}
if (x > this.right) {
this.right = x;
}
if (y > this.top) {
this.top = y;
}
if (y < this.bottom) {
this.bottom = y;
}
}
}
/**
* Initialize a Rectangle to a 1 unit square centered at 0 x 0 transformed
* by a model matrix.
* @param {Array.<number>} m A 4x4 matrix to transform the rectangle by.
* @tutorial Rectangle-AABB-Matrix
*/
initFromModelMatrix (m) {
// In 2D space, we will soon use the 2x2 "top left" scale and rotation
// submatrix, while we store and the 1x2 "top right" that position
// vector.
const m30 = m[(3 * 4) + 0];
const m31 = m[(3 * 4) + 1];
// "Transform" a (0.5, 0.5) vector by the scale and rotation matrix but
// sum the absolute of each component instead of use the signed values.
const x = Math.abs(0.5 * m[(0 * 4) + 0]) + Math.abs(0.5 * m[(1 * 4) + 0]);
const y = Math.abs(0.5 * m[(0 * 4) + 1]) + Math.abs(0.5 * m[(1 * 4) + 1]);
// And adding them to the position components initializes our Rectangle.
this.left = -x + m30;
this.right = x + m30;
this.top = y + m31;
this.bottom = -y + m31;
}
/**
* Determine if this Rectangle intersects some other.
* Note that this is a comparison assuming the Rectangle was
* initialized with Scratch-space bounds or points.
* @param {!Rectangle} other Rectangle to check if intersecting.
* @return {boolean} True if this Rectangle intersects other.
*/
intersects (other) {
return (
this.left <= other.right &&
other.left <= this.right &&
this.top >= other.bottom &&
other.top >= this.bottom
);
}
/**
* Determine if this Rectangle fully contains some other.
* Note that this is a comparison assuming the Rectangle was
* initialized with Scratch-space bounds or points.
* @param {!Rectangle} other Rectangle to check if fully contained.
* @return {boolean} True if this Rectangle fully contains other.
*/
contains (other) {
return (
other.left > this.left &&
other.right < this.right &&
other.top < this.top &&
other.bottom > this.bottom
);
}
/**
* Clamp a Rectangle to bounds.
* @param {number} left Left clamp.
* @param {number} right Right clamp.
* @param {number} bottom Bottom clamp.
* @param {number} top Top clamp.
*/
clamp (left, right, bottom, top) {
this.left = Math.max(this.left, left);
this.right = Math.min(this.right, right);
this.bottom = Math.max(this.bottom, bottom);
this.top = Math.min(this.top, top);
this.left = Math.min(this.left, right);
this.right = Math.max(this.right, left);
this.bottom = Math.min(this.bottom, top);
this.top = Math.max(this.top, bottom);
}
/**
* Push out the Rectangle to integer bounds.
*/
snapToInt () {
this.left = Math.floor(this.left);
this.right = Math.ceil(this.right);
this.bottom = Math.floor(this.bottom);
this.top = Math.ceil(this.top);
}
/**
* Compute the intersection of two bounding Rectangles.
* Could be an impossible box if they don't intersect.
* @param {Rectangle} a One rectangle
* @param {Rectangle} b Other rectangle
* @param {?Rectangle} result A resulting storage rectangle (safe to pass
* a or b if you want to overwrite one)
* @returns {Rectangle} resulting rectangle
*/
static intersect (a, b, result = new Rectangle()) {
result.left = Math.max(a.left, b.left);
result.right = Math.min(a.right, b.right);
result.top = Math.min(a.top, b.top);
result.bottom = Math.max(a.bottom, b.bottom);
return result;
}
/**
* Compute the union of two bounding Rectangles.
* @param {Rectangle} a One rectangle
* @param {Rectangle} b Other rectangle
* @param {?Rectangle} result A resulting storage rectangle (safe to pass
* a or b if you want to overwrite one)
* @returns {Rectangle} resulting rectangle
*/
static union (a, b, result = new Rectangle()) {
result.left = Math.min(a.left, b.left);
result.right = Math.max(a.right, b.right);
// Scratch Space - +y is up
result.top = Math.max(a.top, b.top);
result.bottom = Math.min(a.bottom, b.bottom);
return result;
}
/**
* Width of the Rectangle.
* @return {number} Width of rectangle.
*/
get width () {
return Math.abs(this.left - this.right);
}
/**
* Height of the Rectangle.
* @return {number} Height of rectangle.
*/
get height () {
return Math.abs(this.top - this.bottom);
}
}
module.exports = Rectangle;

View File

@@ -0,0 +1,37 @@
/** @module RenderConstants */
/**
* Various constants meant for use throughout the renderer.
* @enum
*/
module.exports = {
/**
* The ID value to use for "no item" or when an object has been disposed.
* @const {int}
*/
ID_NONE: -1,
/**
* @enum {string}
*/
Events: {
/**
* Event emitted when the high quality render option changes.
*/
UseHighQualityRenderChanged: 'UseHighQualityRenderChanged',
/**
* Event emitted when the private skin access option changes.
*/
AllowPrivateSkinAccessChanged: 'AllowPrivateSkinAccessChanged',
/**
* NativeSizeChanged event
*
* @event RenderWebGL#event:NativeSizeChanged
* @type {object}
* @property {Array<int>} newSize - the new size of the renderer
*/
NativeSizeChanged: 'NativeSizeChanged'
}
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,245 @@
const twgl = require('twgl.js');
const Skin = require('./Skin');
const {loadSvgString, serializeSvgToString} = require('@turbowarp/scratch-svg-renderer');
const ShaderManager = require('./ShaderManager');
/**
* All scaled renderings of the SVG are stored in an array. The 1.0 scale of
* the SVG is stored at the 8th index. The smallest possible 1 / 256 scale
* rendering is stored at the 0th index.
* @const {number}
*/
const INDEX_OFFSET = 8;
class SVGSkin extends Skin {
/**
* Create a new SVG skin.
* @param {!int} id - The ID for this Skin.
* @param {!RenderWebGL} renderer - The renderer which will use this skin.
* @constructor
* @extends Skin
*/
constructor (id, renderer) {
super(id, renderer);
/** @type {HTMLImageElement} */
this._svgImage = document.createElement('img');
/** @type {boolean} */
this._svgImageLoaded = false;
/** @type {Array<number>} */
this._size = [0, 0];
/** @type {HTMLCanvasElement} */
this._canvas = document.createElement('canvas');
/** @type {CanvasRenderingContext2D} */
this._context = this._canvas.getContext('2d');
/** @type {Array<WebGLTexture>} */
this._scaledMIPs = [];
/** @type {number} */
this._largestMIPScale = 0;
/**
* Ratio of the size of the SVG and the max size of the WebGL texture
* @type {Number}
*/
this._maxTextureScale = 1;
}
/**
* Dispose of this object. Do not use it after calling this method.
*/
dispose () {
this.resetMIPs();
super.dispose();
}
/**
* @return {Array<number>} the natural size, in Scratch units, of this skin.
*/
get size () {
return [this._size[0], this._size[1]];
}
useNearest (scale, drawable) {
// If the effect bits for mosaic, pixelate, whirl, or fisheye are set, use linear
if ((drawable.enabledEffects & (
ShaderManager.EFFECT_INFO.fisheye.mask |
ShaderManager.EFFECT_INFO.whirl.mask |
ShaderManager.EFFECT_INFO.pixelate.mask |
ShaderManager.EFFECT_INFO.mosaic.mask
)) !== 0) {
return false;
}
// We can't use nearest neighbor unless we are a multiple of 90 rotation
if (drawable._direction % 90 !== 0) {
return false;
}
// Because SVG skins' bounding boxes are currently not pixel-aligned, the idea here is to hide blurriness
// by using nearest-neighbor scaling if one screen-space pixel is "close enough" to one texture pixel.
// If the scale of the skin is very close to 100 (0.99999 variance is okay I guess)
// TODO: Make this check more precise. We should use nearest if there's less than one pixel's difference
// between the screen-space and texture-space sizes of the skin. Mipmaps make this harder because there are
// multiple textures (and hence multiple texture spaces) and we need to know which one to choose.
if (Math.abs(scale[0]) > 99 && Math.abs(scale[0]) < 101 &&
Math.abs(scale[1]) > 99 && Math.abs(scale[1]) < 101) {
return true;
}
return false;
}
/**
* Create a MIP for a given scale.
* @param {number} scale - The relative size of the MIP
* @return {SVGMIP} An object that handles creating and updating SVG textures.
*/
createMIP (scale) {
const isLargestMIP = this._largestMIPScale < scale;
// TW: Silhouette will lazily read image data from our <canvas>. However, this canvas is shared
// between the Skin and Silhouette so changing it here can mess up Silhouette. To prevent that,
// we will force the silhouette to synchronously read the image data before we mutate the
// canvas, unless the new MIP is the largest MIP, in which case doing so is unnecessary as we
// will update the silhouette later anyways.
if (!isLargestMIP) {
this._silhouette.unlazy();
}
const [width, height] = this._size;
this._canvas.width = width * scale;
this._canvas.height = height * scale;
if (
this._canvas.width <= 0 ||
this._canvas.height <= 0 ||
// Even if the canvas at the current scale has a nonzero size, the image's dimensions are floored
// pre-scaling; e.g. if an image has a width of 0.4 and is being rendered at 3x scale, the canvas will have
// a width of 1, but the image's width will be rounded down to 0 on some browsers (Firefox) prior to being
// drawn at that scale, resulting in an IndexSizeError if we attempt to draw it.
this._svgImage.naturalWidth <= 0 ||
this._svgImage.naturalHeight <= 0
) return super.getTexture();
this._context.clearRect(0, 0, this._canvas.width, this._canvas.height);
this._context.setTransform(scale, 0, 0, scale, 0, 0);
this._context.drawImage(this._svgImage, 0, 0);
// TW: Reading image data from <canvas> is very slow and causes animations to stutter,
// so we just use the canvas directly instead.
const textureData = this._canvas;
const textureOptions = {
auto: false,
wrap: this._renderer.gl.CLAMP_TO_EDGE,
src: textureData,
premultiplyAlpha: true
};
const mip = twgl.createTexture(this._renderer.gl, textureOptions);
// Check if this is the largest MIP created so far. Currently, silhouettes only get scaled up.
if (isLargestMIP) {
this._silhouette.update(textureData);
this._largestMIPScale = scale;
}
return mip;
}
updateSilhouette (scale = [100, 100]) {
// Ensure a silhouette exists.
this.getTexture(scale);
this._silhouette.unlazy();
}
/**
* @param {Array<number>} scale - The scaling factors to be used, each in the [0,100] range.
* @return {WebGLTexture} The GL texture representation of this skin when drawing at the given scale.
*/
getTexture (scale) {
// The texture only ever gets uniform scale. Take the larger of the two axes.
const scaleMax = scale ? Math.max(Math.abs(scale[0]), Math.abs(scale[1])) : 100;
const requestedScale = Math.min(scaleMax / 100, this._maxTextureScale);
// Math.ceil(Math.log2(scale)) means we use the "1x" texture at (0.5, 1] scale,
// the "2x" texture at (1, 2] scale, the "4x" texture at (2, 4] scale, etc.
// This means that one texture pixel will always be between 0.5x and 1x the size of one rendered pixel,
// but never bigger than one rendered pixel--this prevents blurriness from blowing up the texture too much.
const mipLevel = Math.max(Math.ceil(Math.log2(requestedScale)) + INDEX_OFFSET, 0);
// Can't use bitwise stuff here because we need to handle negative exponents
const mipScale = Math.pow(2, mipLevel - INDEX_OFFSET);
if (this._svgImageLoaded && !this._scaledMIPs[mipLevel]) {
this._scaledMIPs[mipLevel] = this.createMIP(mipScale);
}
return this._scaledMIPs[mipLevel] || super.getTexture();
}
/**
* Do a hard reset of the existing MIPs by deleting them.
*/
resetMIPs () {
this._scaledMIPs.forEach(oldMIP => this._renderer.gl.deleteTexture(oldMIP));
this._scaledMIPs.length = 0;
this._largestMIPScale = 0;
}
/**
* Set the contents of this skin to a snapshot of the provided SVG data.
* @param {string} svgData - new SVG to use.
* @param {Array<number>} [rotationCenter] - Optional rotation center for the SVG. If not supplied, it will be
* calculated from the bounding box
* @fires Skin.event:WasAltered
*/
setSVG (svgData, rotationCenter) {
const svgTag = loadSvgString(svgData);
const svgText = serializeSvgToString(svgTag, this._renderer.customFonts);
this._svgImageLoaded = false;
const {x, y, width, height} = svgTag.viewBox.baseVal;
// While we're setting the size before the image is loaded, this doesn't cause the skin to appear with the wrong
// size for a few frames while the new image is loading, because we don't emit the `WasAltered` event, telling
// drawables using this skin to update, until the image is loaded.
// We need to do this because the VM reads the skin's `size` directly after calling `setSVG`.
// TODO: return a Promise so that the VM can read the skin's `size` after the image is loaded.
this._size[0] = width;
this._size[1] = height;
// If there is another load already in progress, replace the old onload to effectively cancel the old load
this._svgImage.onload = () => {
if (width === 0 || height === 0) {
super.setEmptyImageData();
return;
}
const maxDimension = Math.ceil(Math.max(width, height));
const rendererMax = this._renderer.maxTextureDimension;
let testScale = 2;
for (testScale; maxDimension * testScale <= rendererMax; testScale *= 2) {
this._maxTextureScale = testScale;
}
this.resetMIPs();
if (typeof rotationCenter === 'undefined') rotationCenter = this.calculateRotationCenter();
// Compensate for viewbox offset.
// See https://github.com/LLK/scratch-render/pull/90.
this._rotationCenter[0] = rotationCenter[0] - x;
this._rotationCenter[1] = rotationCenter[1] - y;
this._svgImageLoaded = true;
this.emitWasAltered();
};
this._svgImage.src = `data:image/svg+xml;utf8,${encodeURIComponent(svgText)}`;
}
}
module.exports = SVGSkin;

View File

@@ -0,0 +1,202 @@
const twgl = require('twgl.js');
class ShaderManager {
/**
* @param {WebGLRenderingContext} gl WebGL rendering context to create shaders for
* @constructor
*/
constructor (gl) {
this._gl = gl;
/**
* The cache of all shaders compiled so far, filled on demand.
* @type {Object<ShaderManager.DRAW_MODE, Array<ProgramInfo>>}
* @private
*/
this._shaderCache = {};
for (const modeName in ShaderManager.DRAW_MODE) {
if (Object.prototype.hasOwnProperty.call(ShaderManager.DRAW_MODE, modeName)) {
this._shaderCache[modeName] = [];
}
}
}
/**
* Fetch the shader for a particular set of active effects.
* Build the shader if necessary.
* @param {ShaderManager.DRAW_MODE} drawMode Draw normally, silhouette, etc.
* @param {int} effectBits Bitmask representing the enabled effects.
* @returns {ProgramInfo} The shader's program info.
*/
getShader (drawMode, effectBits) {
const cache = this._shaderCache[drawMode];
if (drawMode === ShaderManager.DRAW_MODE.silhouette) {
// Silhouette mode isn't affected by these effects.
effectBits &= ~(ShaderManager.EFFECT_INFO.color.mask | ShaderManager.EFFECT_INFO.brightness.mask);
}
let shader = cache[effectBits];
if (!shader) {
shader = cache[effectBits] = this._buildShader(drawMode, effectBits);
}
return shader;
}
/**
* Build the shader for a particular set of active effects.
* @param {ShaderManager.DRAW_MODE} drawMode Draw normally, silhouette, etc.
* @param {int} effectBits Bitmask representing the enabled effects.
* @returns {ProgramInfo} The new shader's program info.
* @private
*/
_buildShader (drawMode, effectBits) {
const numEffects = ShaderManager.EFFECTS.length;
const defines = [
`#define DRAW_MODE_${drawMode}`
];
for (let index = 0; index < numEffects; ++index) {
if ((effectBits & (1 << index)) !== 0) {
defines.push(`#define ENABLE_${ShaderManager.EFFECTS[index]}`);
}
}
const definesText = `${defines.join('\n')}\n`;
/* eslint-disable global-require */
const vsFullText = definesText + require('raw-loader!./shaders/sprite.vert');
const fsFullText = definesText + require('raw-loader!./shaders/sprite.frag');
/* eslint-enable global-require */
let errorMessage = null;
const onError = newError => {
// twgl won't log the error when we provide a custom error callback, so log it ourselves
console.error(newError);
// For the error that we throw, just include the actual error from WebGL, not all the fancy
// extras that twgl adds to the error messages.
const match = newError.match(/\*\*\* Error compiling shader: ([\s\S]+)/);
errorMessage = match ? match[1].trim() : newError;
};
const program = twgl.createProgramInfo(this._gl, [vsFullText, fsFullText], null, null, onError);
if (!program) {
throw new Error(`Failed to compile shader (mode ${drawMode}, effects ${effectBits}): ${errorMessage}`);
}
return program;
}
}
/**
* @typedef {object} ShaderManager.Effect
* @prop {int} mask - The bit in 'effectBits' representing the effect.
* @prop {function} converter - A conversion function which takes a Scratch value (generally in the range
* 0..100 or -100..100) and maps it to a value useful to the shader. This
* mapping may not be reversible.
* @prop {boolean} shapeChanges - Whether the effect could change the drawn shape.
*/
/**
* Mapping of each effect name to info about that effect.
* @enum {ShaderManager.Effect}
*/
ShaderManager.EFFECT_INFO = {
/** Color effect */
color: {
uniformName: 'u_color',
mask: 1 << 0,
converter: x => (x / 200) % 1,
shapeChanges: false
},
/** Fisheye effect */
fisheye: {
uniformName: 'u_fisheye',
mask: 1 << 1,
converter: x => Math.max(0, (x + 100) / 100),
shapeChanges: true
},
/** Whirl effect */
whirl: {
uniformName: 'u_whirl',
mask: 1 << 2,
converter: x => -x * Math.PI / 180,
shapeChanges: true
},
/** Pixelate effect */
pixelate: {
uniformName: 'u_pixelate',
mask: 1 << 3,
converter: x => Math.abs(x) / 10,
shapeChanges: true
},
/** Mosaic effect */
mosaic: {
uniformName: 'u_mosaic',
mask: 1 << 4,
converter: x => {
x = Math.round((Math.abs(x) + 10) / 10);
/** @todo cap by Math.min(srcWidth, srcHeight) */
return Math.max(1, Math.min(x, 512));
},
shapeChanges: true
},
/** Brightness effect */
brightness: {
uniformName: 'u_brightness',
mask: 1 << 5,
converter: x => Math.max(-100, Math.min(x, 100)) / 100,
shapeChanges: false
},
/** Ghost effect */
ghost: {
uniformName: 'u_ghost',
mask: 1 << 6,
converter: x => 1 - (Math.max(0, Math.min(x, 100)) / 100),
shapeChanges: false
}
};
/**
* The name of each supported effect.
* @type {Array}
*/
ShaderManager.EFFECTS = Object.keys(ShaderManager.EFFECT_INFO);
/**
* The available draw modes.
* @readonly
* @enum {string}
*/
ShaderManager.DRAW_MODE = {
/**
* Draw normally. Its output will use premultiplied alpha.
*/
default: 'default',
/**
* Draw with non-premultiplied alpha. Useful for reading pixels from GL into an ImageData object.
*/
straightAlpha: 'straightAlpha',
/**
* Draw a silhouette using a solid color.
*/
silhouette: 'silhouette',
/**
* Draw only the parts of the drawable which match a particular color.
*/
colorMask: 'colorMask',
/**
* Draw a line with caps.
*/
line: 'line',
/**
* Draw the background in a certain color. Must sometimes be used instead of gl.clear.
*/
background: 'background'
};
module.exports = ShaderManager;

View File

@@ -0,0 +1,280 @@
/**
* @fileoverview
* A representation of a Skin's silhouette that can test if a point on the skin
* renders a pixel where it is drawn.
*/
/**
* <canvas> element used to update Silhouette data from skin bitmap data.
* @type {CanvasElement}
*/
let __SilhouetteUpdateCanvas;
// Optimized Math.min and Math.max for integers;
// taken from https://web.archive.org/web/20190716181049/http://guihaire.com/code/?p=549
const intMin = (i, j) => j ^ ((i ^ j) & ((i - j) >> 31));
const intMax = (i, j) => i ^ ((i ^ j) & ((i - j) >> 31));
/**
* Internal helper function (in hopes that compiler can inline). Get a pixel
* from silhouette data, or 0 if outside it's bounds.
* @private
* @param {Silhouette} silhouette - has data width and height
* @param {number} x - x
* @param {number} y - y
* @return {number} Alpha value for x/y position
*/
const getPoint = ({_width: width, _height: height, _colorData: data}, x, y) => {
// 0 if outside bounds, otherwise read from data.
if (x >= width || y >= height || x < 0 || y < 0) {
return 0;
}
return data[(((y * width) + x) * 4) + 3];
};
/**
* Memory buffers for doing 4 corner sampling for linear interpolation
*/
const __cornerWork = [
new Uint8ClampedArray(4),
new Uint8ClampedArray(4),
new Uint8ClampedArray(4),
new Uint8ClampedArray(4)
];
/**
* Get the color from a given silhouette at an x/y local texture position.
* Multiply color values by alpha for proper blending.
* @param {Silhouette} $0 The silhouette to sample.
* @param {number} x X position of texture [0, width).
* @param {number} y Y position of texture [0, height).
* @param {Uint8ClampedArray} dst A color 4b space.
* @return {Uint8ClampedArray} The dst vector.
*/
const getColor4b = ({_width: width, _height: height, _colorData: data}, x, y, dst) => {
// Clamp coords to edge, matching GL_CLAMP_TO_EDGE.
// (See github.com/LLK/scratch-render/blob/954cfff02b08069a082cbedd415c1fecd9b1e4fb/src/BitmapSkin.js#L88)
x = intMax(0, intMin(x, width - 1));
y = intMax(0, intMin(y, height - 1));
// 0 if outside bounds, otherwise read from data.
if (x >= width || y >= height || x < 0 || y < 0) {
return dst.fill(0);
}
const offset = ((y * width) + x) * 4;
// premultiply alpha
const alpha = data[offset + 3] / 255;
dst[0] = data[offset] * alpha;
dst[1] = data[offset + 1] * alpha;
dst[2] = data[offset + 2] * alpha;
dst[3] = data[offset + 3];
return dst;
};
/**
* Get the color from a given silhouette at an x/y local texture position.
* Do not multiply color values by alpha, as it has already been done.
* @param {Silhouette} $0 The silhouette to sample.
* @param {number} x X position of texture [0, width).
* @param {number} y Y position of texture [0, height).
* @param {Uint8ClampedArray} dst A color 4b space.
* @return {Uint8ClampedArray} The dst vector.
*/
const getPremultipliedColor4b = ({_width: width, _height: height, _colorData: data}, x, y, dst) => {
// Clamp coords to edge, matching GL_CLAMP_TO_EDGE.
x = intMax(0, intMin(x, width - 1));
y = intMax(0, intMin(y, height - 1));
const offset = ((y * width) + x) * 4;
dst[0] = data[offset];
dst[1] = data[offset + 1];
dst[2] = data[offset + 2];
dst[3] = data[offset + 3];
return dst;
};
class Silhouette {
constructor () {
/**
* The width of the data representing the current skin data.
* @type {number}
*/
this._width = 0;
/**
* The height of the data representing the current skin date.
* @type {number}
*/
this._height = 0;
this._lazyData = null;
/**
* The data representing a skin's silhouette shape.
* @type {Uint8ClampedArray}
*/
this._colorData = null;
// By default, silhouettes are assumed not to contain premultiplied image data,
// so when we get a color, we want to multiply it by its alpha channel.
// Point `_getColor` to the version of the function that multiplies.
this._getColor = getColor4b;
this.colorAtNearest = this.colorAtLinear = (_, dst) => dst.fill(0);
}
/**
* Update this silhouette with the bitmapData for a skin.
* @param {ImageData|HTMLCanvasElement|HTMLImageElement} bitmapData An image, canvas or other element that the skin
* @param {boolean} isPremultiplied True if the source bitmap data comes premultiplied (e.g. from readPixels).
* rendering can be queried from.
*/
update (bitmapData, isPremultiplied = false) {
let imageData;
if (bitmapData instanceof ImageData) {
// If handed ImageData directly, use it directly.
imageData = bitmapData;
this._width = bitmapData.width;
this._height = bitmapData.height;
this._lazyData = null;
this._colorData = imageData.data;
} else {
// TW: No reason to read the image data now, there's a high chance it won't be needed and will
// just waste memory and CPU time. We'll read it lazily, only when necessary.
this._width = bitmapData.width;
this._height = bitmapData.height;
if (!(this._width && this._height)) {
// TW: It might seem really weird to return here before updating anything else, but this is what
// LLK/scratch-render does.
return;
}
this._lazyData = bitmapData;
this._colorData = null;
}
if (isPremultiplied) {
this._getColor = getPremultipliedColor4b;
} else {
this._getColor = getColor4b;
}
// delete our custom overriden "uninitalized" color functions
// let the prototype work for itself
delete this.colorAtNearest;
delete this.colorAtLinear;
}
unlazy () {
if (!this._lazyData) {
return;
}
const width = this._lazyData.width;
const height = this._lazyData.height;
if (width && height) {
const canvas = Silhouette._updateCanvas();
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, width, height);
ctx.drawImage(this._lazyData, 0, 0, width, height);
const textureData = ctx.getImageData(0, 0, width, height);
this._colorData = textureData.data;
}
this._lazyData = null;
}
/**
* Sample a color from the silhouette at a given local position using
* "nearest neighbor"
* @param {twgl.v3} vec [x,y] texture space (0-1)
* @param {Uint8ClampedArray} dst The memory buffer to store the value in. (4 bytes)
* @returns {Uint8ClampedArray} dst
*/
colorAtNearest (vec, dst) {
return this._getColor(
this,
Math.floor(vec[0] * (this._width - 1)),
Math.floor(vec[1] * (this._height - 1)),
dst
);
}
/**
* Sample a color from the silhouette at a given local position using
* "linear interpolation"
* @param {twgl.v3} vec [x,y] texture space (0-1)
* @param {Uint8ClampedArray} dst The memory buffer to store the value in. (4 bytes)
* @returns {Uint8ClampedArray} dst
*/
colorAtLinear (vec, dst) {
const x = vec[0] * (this._width - 1);
const y = vec[1] * (this._height - 1);
const x1D = x % 1;
const y1D = y % 1;
const x0D = 1 - x1D;
const y0D = 1 - y1D;
const xFloor = Math.floor(x);
const yFloor = Math.floor(y);
const x0y0 = this._getColor(this, xFloor, yFloor, __cornerWork[0]);
const x1y0 = this._getColor(this, xFloor + 1, yFloor, __cornerWork[1]);
const x0y1 = this._getColor(this, xFloor, yFloor + 1, __cornerWork[2]);
const x1y1 = this._getColor(this, xFloor + 1, yFloor + 1, __cornerWork[3]);
dst[0] = (x0y0[0] * x0D * y0D) + (x0y1[0] * x0D * y1D) + (x1y0[0] * x1D * y0D) + (x1y1[0] * x1D * y1D);
dst[1] = (x0y0[1] * x0D * y0D) + (x0y1[1] * x0D * y1D) + (x1y0[1] * x1D * y0D) + (x1y1[1] * x1D * y1D);
dst[2] = (x0y0[2] * x0D * y0D) + (x0y1[2] * x0D * y1D) + (x1y0[2] * x1D * y0D) + (x1y1[2] * x1D * y1D);
dst[3] = (x0y0[3] * x0D * y0D) + (x0y1[3] * x0D * y1D) + (x1y0[3] * x1D * y0D) + (x1y1[3] * x1D * y1D);
return dst;
}
/**
* Test if texture coordinate touches the silhouette using nearest neighbor.
* @param {twgl.v3} vec A texture coordinate.
* @return {boolean} If the nearest pixel has an alpha value.
*/
isTouchingNearest (vec) {
if (!this._colorData) return;
return getPoint(
this,
Math.floor(vec[0] * (this._width - 1)),
Math.floor(vec[1] * (this._height - 1))
) > 0;
}
/**
* Test to see if any of the 4 pixels used in the linear interpolate touch
* the silhouette.
* @param {twgl.v3} vec A texture coordinate.
* @return {boolean} Any of the pixels have some alpha.
*/
isTouchingLinear (vec) {
if (!this._colorData) return;
const x = Math.floor(vec[0] * (this._width - 1));
const y = Math.floor(vec[1] * (this._height - 1));
return getPoint(this, x, y) > 0 ||
getPoint(this, x + 1, y) > 0 ||
getPoint(this, x, y + 1) > 0 ||
getPoint(this, x + 1, y + 1) > 0;
}
/**
* Get the canvas element reused by Silhouettes to update their data with.
* @private
* @return {CanvasElement} A canvas to draw bitmap data to.
*/
static _updateCanvas () {
if (typeof __SilhouetteUpdateCanvas === 'undefined') {
__SilhouetteUpdateCanvas = document.createElement('canvas');
}
return __SilhouetteUpdateCanvas;
}
}
module.exports = Silhouette;

231
scratch-render/src/Skin.js Normal file
View File

@@ -0,0 +1,231 @@
const twgl = require('twgl.js');
const RenderConstants = require('./RenderConstants');
const Silhouette = require('./Silhouette');
class Skin {
/**
* Create a Skin, which stores and/or generates textures for use in rendering.
* @param {int} id - The unique ID for this Skin.
* @param {RenderWebGL} renderer - The renderer which will use this skin.
* @constructor
*/
constructor (id, renderer) {
/** @type {RenderWebGL} */
this._renderer = renderer;
/** @type {int} */
this._id = id;
/** @type {Vec3} */
this._rotationCenter = twgl.v3.create(0, 0);
/** @type {WebGLTexture} */
this._texture = null;
/**
* The uniforms to be used by the vertex and pixel shaders.
* Some of these are used by other parts of the renderer as well.
* @type {Object.<string,*>}
* @private
*/
this._uniforms = {
/**
* The nominal (not necessarily current) size of the current skin.
* @type {Array<number>}
*/
u_skinSize: [0, 0],
/**
* The actual WebGL texture object for the skin.
* @type {WebGLTexture}
*/
u_skin: null
};
/**
* A silhouette to store touching data, skins are responsible for keeping it up to date.
* @protected
*/
this._silhouette = new Silhouette();
/**
* Whether this skin might include private information about the user.
*/
this.private = false;
}
/**
* Dispose of this object. Do not use it after calling this method.
*/
dispose () {
this._id = RenderConstants.ID_NONE;
}
/**
* @return {int} the unique ID for this Skin.
*/
get id () {
return this._id;
}
/**
* @returns {Vec3} the origin, in object space, about which this Skin should rotate.
*/
get rotationCenter () {
return this._rotationCenter;
}
/**
* @abstract
* @return {Array<number>} the "native" size, in texels, of this skin.
*/
get size () {
return [0, 0];
}
/**
* Should this skin's texture be filtered with nearest-neighbor or linear interpolation at the given scale?
* @param {?Array<Number>} scale The screen-space X and Y scaling factors at which this skin's texture will be
* displayed, as percentages (100 means 1 "native size" unit is 1 screen pixel; 200 means 2 screen pixels, etc).
* @param {Drawable} drawable The drawable that this skin's texture will be applied to.
* @return {boolean} True if this skin's texture, as returned by {@link getTexture}, should be filtered with
* nearest-neighbor interpolation.
*/
// eslint-disable-next-line no-unused-vars
useNearest (scale, drawable) {
return true;
}
/**
* Get the center of the current bounding box
* @return {Array<number>} the center of the current bounding box
*/
calculateRotationCenter () {
return [this.size[0] / 2, this.size[1] / 2];
}
/**
* @abstract
* @param {Array<number>} scale - The scaling factors to be used.
* @return {WebGLTexture} The GL texture representation of this skin when drawing at the given size.
*/
// eslint-disable-next-line no-unused-vars
getTexture (scale) {
return this._emptyImageTexture;
}
/**
* Get the bounds of the drawable for determining its fenced position.
* @param {Array<number>} drawable - The Drawable instance this skin is using.
* @param {?Rectangle} result - Optional destination for bounds calculation.
* @return {!Rectangle} The drawable's bounds. For compatibility with Scratch 2, we always use getAABB.
*/
getFenceBounds (drawable, result) {
return drawable.getAABB(result);
}
/**
* Update and returns the uniforms for this skin.
* @param {Array<number>} scale - The scaling factors to be used.
* @returns {object.<string, *>} the shader uniforms to be used when rendering with this Skin.
*/
getUniforms (scale) {
this._uniforms.u_skin = this.getTexture(scale);
this._uniforms.u_skinSize = this.size;
return this._uniforms;
}
emitWasAltered () {
this._renderer.skinWasAltered(this);
}
/**
* If the skin defers silhouette operations until the last possible minute,
* this will be called before isTouching uses the silhouette.
*/
updateSilhouette () {
this._silhouette.unlazy();
}
/**
* Set this skin's texture to the given image.
* @param {ImageData|HTMLCanvasElement} textureData - The canvas or image data to set the texture to.
*/
_setTexture (textureData) {
const gl = this._renderer.gl;
gl.bindTexture(gl.TEXTURE_2D, this._texture);
// Premultiplied alpha is necessary for proper blending.
// See http://www.realtimerendering.com/blog/gpus-prefer-premultiplication/
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, textureData);
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
this._silhouette.update(textureData);
}
/**
* Set the contents of this skin to an empty skin.
* @fires Skin.event:WasAltered
*/
setEmptyImageData () {
// Free up the current reference to the _texture
this._texture = null;
if (!this._emptyImageData) {
// Create a transparent pixel
this._emptyImageData = new ImageData(1, 1);
// Create a new texture and update the silhouette
const gl = this._renderer.gl;
const textureOptions = {
auto: true,
wrap: gl.CLAMP_TO_EDGE,
src: this._emptyImageData
};
// Note: we're using _emptyImageTexture here instead of _texture
// so that we can cache this empty texture for later use as needed.
// this._texture can get modified by other skins (e.g. BitmapSkin
// and SVGSkin, so we can't use that same field for caching)
this._emptyImageTexture = twgl.createTexture(gl, textureOptions);
}
this._rotationCenter[0] = 0;
this._rotationCenter[1] = 0;
this._silhouette.update(this._emptyImageData);
this.emitWasAltered();
}
/**
* Does this point touch an opaque or translucent point on this skin?
* Nearest Neighbor version
* The caller is responsible for ensuring this skin's silhouette is up-to-date.
* @see updateSilhouette
* @see Drawable.updateCPURenderAttributes
* @param {twgl.v3} vec A texture coordinate.
* @return {boolean} Did it touch?
*/
isTouchingNearest (vec) {
return this._silhouette.isTouchingNearest(vec);
}
/**
* Does this point touch an opaque or translucent point on this skin?
* Linear Interpolation version
* The caller is responsible for ensuring this skin's silhouette is up-to-date.
* @see updateSilhouette
* @see Drawable.updateCPURenderAttributes
* @param {twgl.v3} vec A texture coordinate.
* @return {boolean} Did it touch?
*/
isTouchingLinear (vec) {
return this._silhouette.isTouchingLinear(vec);
}
}
module.exports = Skin;

View File

@@ -0,0 +1,282 @@
const twgl = require('twgl.js');
const CanvasMeasurementProvider = require('./util/canvas-measurement-provider');
const Skin = require('./Skin');
const BubbleStyle = {
MAX_LINE_WIDTH: 170, // Maximum width, in Scratch pixels, of a single line of text
MIN_WIDTH: 50, // Minimum width, in Scratch pixels, of a text bubble
STROKE_WIDTH: 4, // Thickness of the stroke around the bubble. Only half's visible because it's drawn under the fill
PADDING: 10, // Padding around the text area
CORNER_RADIUS: 16, // Radius of the rounded corners
TAIL_HEIGHT: 12, // Height of the speech bubble's "tail". Probably should be a constant.
FONT: 'Helvetica', // Font to render the text with
FONT_SIZE: 14, // Font size, in Scratch pixels
FONT_HEIGHT_RATIO: 0.9, // Height, in Scratch pixels, of the text, as a proportion of the font's size
LINE_HEIGHT: 16, // Spacing between each line of text
COLORS: {
BUBBLE_FILL: 'white',
BUBBLE_STROKE: 'rgba(0, 0, 0, 0.15)',
TEXT_FILL: '#575E75'
}
};
const MAX_SCALE = 10;
class TextBubbleSkin extends Skin {
/**
* Create a new text bubble skin.
* @param {!int} id - The ID for this Skin.
* @param {!RenderWebGL} renderer - The renderer which will use this skin.
* @constructor
* @extends Skin
*/
constructor (id, renderer) {
super(id, renderer);
/** @type {HTMLCanvasElement} */
this._canvas = document.createElement('canvas');
/** @type {Array<number>} */
this._size = [0, 0];
/** @type {number} */
this._renderedScale = 0;
/** @type {Array<string>} */
this._lines = [];
/** @type {object} */
this._textAreaSize = {width: 0, height: 0};
/** @type {string} */
this._bubbleType = '';
/** @type {boolean} */
this._pointsLeft = false;
/** @type {boolean} */
this._textDirty = true;
/** @type {boolean} */
this._textureDirty = true;
this.measurementProvider = new CanvasMeasurementProvider(this._canvas.getContext('2d'));
this.textWrapper = renderer.createTextWrapper(this.measurementProvider);
this._restyleCanvas();
}
/**
* Dispose of this object. Do not use it after calling this method.
*/
dispose () {
if (this._texture) {
this._renderer.gl.deleteTexture(this._texture);
this._texture = null;
}
this._canvas = null;
super.dispose();
}
/**
* @return {Array<number>} the dimensions, in Scratch units, of this skin.
*/
get size () {
if (this._textDirty) {
this._reflowLines();
}
return this._size;
}
/**
* Set parameters for this text bubble.
* @param {!string} type - either "say" or "think".
* @param {!string} text - the text for the bubble.
* @param {!boolean} pointsLeft - which side the bubble is pointing.
*/
setTextBubble (type, text, pointsLeft) {
this._text = text;
this._bubbleType = type;
this._pointsLeft = pointsLeft;
this._textDirty = true;
this._textureDirty = true;
this.emitWasAltered();
}
/**
* Re-style the canvas after resizing it. This is necessary to ensure proper text measurement.
*/
_restyleCanvas () {
this._canvas.getContext('2d').font = `${BubbleStyle.FONT_SIZE}px ${BubbleStyle.FONT}, sans-serif`;
}
/**
* Update the array of wrapped lines and the text dimensions.
*/
_reflowLines () {
this._lines = this.textWrapper.wrapText(BubbleStyle.MAX_LINE_WIDTH, this._text);
// Measure width of longest line to avoid extra-wide bubbles
let longestLineWidth = 0;
for (const line of this._lines) {
longestLineWidth = Math.max(longestLineWidth, this.measurementProvider.measureText(line));
}
// Calculate the canvas-space sizes of the padded text area and full text bubble
const paddedWidth = Math.max(longestLineWidth, BubbleStyle.MIN_WIDTH) + (BubbleStyle.PADDING * 2);
const paddedHeight = (BubbleStyle.LINE_HEIGHT * this._lines.length) + (BubbleStyle.PADDING * 2);
this._textAreaSize.width = paddedWidth;
this._textAreaSize.height = paddedHeight;
this._size[0] = paddedWidth + BubbleStyle.STROKE_WIDTH;
this._size[1] = paddedHeight + BubbleStyle.STROKE_WIDTH + BubbleStyle.TAIL_HEIGHT;
this._textDirty = false;
}
/**
* Render this text bubble at a certain scale, using the current parameters, to the canvas.
* @param {number} scale The scale to render the bubble at
*/
_renderTextBubble (scale) {
const ctx = this._canvas.getContext('2d');
if (this._textDirty) {
this._reflowLines();
}
// Calculate the canvas-space sizes of the padded text area and full text bubble
const paddedWidth = this._textAreaSize.width;
const paddedHeight = this._textAreaSize.height;
// Resize the canvas to the correct screen-space size
this._canvas.width = Math.ceil(this._size[0] * scale);
this._canvas.height = Math.ceil(this._size[1] * scale);
this._restyleCanvas();
// Reset the transform before clearing to ensure 100% clearage
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, this._canvas.width, this._canvas.height);
ctx.scale(scale, scale);
ctx.translate(BubbleStyle.STROKE_WIDTH * 0.5, BubbleStyle.STROKE_WIDTH * 0.5);
// If the text bubble points leftward, flip the canvas
ctx.save();
if (this._pointsLeft) {
ctx.scale(-1, 1);
ctx.translate(-paddedWidth, 0);
}
// Draw the bubble's rounded borders
ctx.beginPath();
ctx.moveTo(BubbleStyle.CORNER_RADIUS, paddedHeight);
ctx.arcTo(0, paddedHeight, 0, paddedHeight - BubbleStyle.CORNER_RADIUS, BubbleStyle.CORNER_RADIUS);
ctx.arcTo(0, 0, paddedWidth, 0, BubbleStyle.CORNER_RADIUS);
ctx.arcTo(paddedWidth, 0, paddedWidth, paddedHeight, BubbleStyle.CORNER_RADIUS);
ctx.arcTo(paddedWidth, paddedHeight, paddedWidth - BubbleStyle.CORNER_RADIUS, paddedHeight,
BubbleStyle.CORNER_RADIUS);
// Translate the canvas so we don't have to do a bunch of width/height arithmetic
ctx.save();
ctx.translate(paddedWidth - BubbleStyle.CORNER_RADIUS, paddedHeight);
// Draw the bubble's "tail"
if (this._bubbleType === 'say') {
// For a speech bubble, draw one swoopy thing
ctx.bezierCurveTo(0, 4, 4, 8, 4, 10);
ctx.arcTo(4, 12, 2, 12, 2);
ctx.bezierCurveTo(-1, 12, -11, 8, -16, 0);
ctx.closePath();
} else {
// For a thinking bubble, draw a partial circle attached to the bubble...
ctx.arc(-16, 0, 4, 0, Math.PI);
ctx.closePath();
// and two circles detached from it
ctx.moveTo(-7, 7.25);
ctx.arc(-9.25, 7.25, 2.25, 0, Math.PI * 2);
ctx.moveTo(0, 9.5);
ctx.arc(-1.5, 9.5, 1.5, 0, Math.PI * 2);
}
// Un-translate the canvas and fill + stroke the text bubble
ctx.restore();
ctx.fillStyle = BubbleStyle.COLORS.BUBBLE_FILL;
ctx.strokeStyle = BubbleStyle.COLORS.BUBBLE_STROKE;
ctx.lineWidth = BubbleStyle.STROKE_WIDTH;
ctx.stroke();
ctx.fill();
// Un-flip the canvas if it was flipped
ctx.restore();
// Draw each line of text
ctx.fillStyle = BubbleStyle.COLORS.TEXT_FILL;
ctx.font = `${BubbleStyle.FONT_SIZE}px ${BubbleStyle.FONT}, sans-serif`;
const lines = this._lines;
for (let lineNumber = 0; lineNumber < lines.length; lineNumber++) {
const line = lines[lineNumber];
ctx.fillText(
line,
BubbleStyle.PADDING,
BubbleStyle.PADDING + (BubbleStyle.LINE_HEIGHT * lineNumber) +
(BubbleStyle.FONT_HEIGHT_RATIO * BubbleStyle.FONT_SIZE)
);
}
this._renderedScale = scale;
}
updateSilhouette (scale = [100, 100]) {
// Ensure a silhouette exists.
this.getTexture(scale);
}
/**
* @param {Array<number>} scale - The scaling factors to be used, each in the [0,100] range.
* @return {WebGLTexture} The GL texture representation of this skin when drawing at the given scale.
*/
getTexture (scale) {
// The texture only ever gets uniform scale. Take the larger of the two axes.
const scaleMax = scale ? Math.max(Math.abs(scale[0]), Math.abs(scale[1])) : 100;
const requestedScale = Math.min(MAX_SCALE, scaleMax / 100);
// If we already rendered the text bubble at this scale, we can skip re-rendering it.
if (this._textureDirty || this._renderedScale !== requestedScale) {
this._renderTextBubble(requestedScale);
this._textureDirty = false;
const context = this._canvas.getContext('2d');
const textureData = context.getImageData(0, 0, this._canvas.width, this._canvas.height);
const gl = this._renderer.gl;
if (this._texture === null) {
const textureOptions = {
auto: false,
wrap: gl.CLAMP_TO_EDGE
};
this._texture = twgl.createTexture(gl, textureOptions);
}
this._setTexture(textureData);
}
return this._texture;
}
}
module.exports = TextBubbleSkin;

View File

@@ -0,0 +1,7 @@
const RenderWebGL = require('./RenderWebGL');
/**
* Export for NPM & Node.js
* @type {RenderWebGL}
*/
module.exports = RenderWebGL;

View File

@@ -0,0 +1,9 @@
module.exports = {
extends: ['scratch'],
env: {
browser: true
},
rules: {
'no-console': 'off'
}
};

View File

@@ -0,0 +1,37 @@
// Adapted from code by Simon Sarris: http://stackoverflow.com/a/10450761
const getMousePos = function (event, element) {
const stylePaddingLeft = parseInt(document.defaultView.getComputedStyle(element, null).paddingLeft, 10) || 0;
const stylePaddingTop = parseInt(document.defaultView.getComputedStyle(element, null).paddingTop, 10) || 0;
const styleBorderLeft = parseInt(document.defaultView.getComputedStyle(element, null).borderLeftWidth, 10) || 0;
const styleBorderTop = parseInt(document.defaultView.getComputedStyle(element, null).borderTopWidth, 10) || 0;
// Some pages have fixed-position bars at the top or left of the page
// They will mess up mouse coordinates and this fixes that
const html = document.body.parentNode;
const htmlTop = html.offsetTop;
const htmlLeft = html.offsetLeft;
// Compute the total offset. It's possible to cache this if you want
let offsetX = 0;
let offsetY = 0;
if (typeof element.offsetParent !== 'undefined') {
do {
offsetX += element.offsetLeft;
offsetY += element.offsetTop;
} while ((element = element.offsetParent));
}
// Add padding and border style widths to offset
// Also add the <html> offsets in case there's a position:fixed bar
// This part is not strictly necessary, it depends on your styling
offsetX += stylePaddingLeft + styleBorderLeft + htmlLeft;
offsetY += stylePaddingTop + styleBorderTop + htmlTop;
// We return a simple javascript object with x and y defined
return {
x: event.pageX - offsetX,
y: event.pageY - offsetY
};
};
module.exports = getMousePos;

View File

@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Scratch WebGL rendering demo</title>
<link rel="stylesheet" type="text/css" href="style.css">
</head>
<body>
<canvas id="scratch-stage" width="10" height="10"></canvas>
<canvas id="debug-canvas" width="10" height="10"></canvas>
<p>
<label for="fudgeproperty">Property to tweak:</label>
<select id="fudgeproperty">
<option value="posx">Position X</option>
<option value="posy">Position Y</option>
<option value="direction">Direction</option>
<option value="scalex">Scale X</option>
<option value="scaley">Scale Y</option>
<option value="scaleboth">Scale (both dimensions)</option>
<option value="color">Color</option>
<option value="fisheye">Fisheye</option>
<option value="whirl">Whirl</option>
<option value="pixelate">Pixelate</option>
<option value="mosaic">Mosaic</option>
<option value="brightness">Brightness</option>
<option value="ghost">Ghost</option>
</select>
<label for="fudge">Property Value:</label>
<input type="range" id="fudge" style="width:50%" value="90" min="-90" max="270" step="any">
</p>
<p>
<label for="stage-scale">Stage scale:</label>
<input type="range" style="width:50%" id="stage-scale" value="1" min="1" max="2.5" step="any">
</p>
<p>
<label for="fudgeMin">Min:</label><input id="fudgeMin" type="number" value="0">
<label for="fudgeMax">Max:</label><input id="fudgeMax" type="number" value="200">
</p>
<script src="playground.js"></script>
</body>
</html>

View File

@@ -0,0 +1,202 @@
const ScratchRender = require('../RenderWebGL');
const getMousePosition = require('./getMousePosition');
const canvas = document.getElementById('scratch-stage');
let fudge = 90;
const renderer = new ScratchRender(canvas);
renderer.setLayerGroupOrdering(['group1']);
const drawableID = renderer.createDrawable('group1');
renderer.updateDrawableProperties(drawableID, {
position: [0, 0],
scale: [100, 100],
direction: 90
});
const WantedSkinType = {
bitmap: 'bitmap',
vector: 'vector',
pen: 'pen'
};
const drawableID2 = renderer.createDrawable('group1');
const wantedSkin = WantedSkinType.vector;
// Bitmap (squirrel)
const image = new Image();
image.addEventListener('load', () => {
const bitmapSkinId = renderer.createBitmapSkin(image);
if (wantedSkin === WantedSkinType.bitmap) {
renderer.updateDrawableProperties(drawableID2, {
skinId: bitmapSkinId
});
}
});
image.crossOrigin = 'anonymous';
image.src = 'https://cdn.assets.scratch.mit.edu/internalapi/asset/7e24c99c1b853e52f8e7f9004416fa34.png/get/';
// SVG (cat 1-a)
const xhr = new XMLHttpRequest();
xhr.addEventListener('load', () => {
const skinId = renderer.createSVGSkin(xhr.responseText);
if (wantedSkin === WantedSkinType.vector) {
renderer.updateDrawableProperties(drawableID2, {
skinId: skinId
});
}
});
xhr.open('GET', 'https://cdn.assets.scratch.mit.edu/internalapi/asset/b7853f557e4426412e64bb3da6531a99.svg/get/');
xhr.send();
if (wantedSkin === WantedSkinType.pen) {
const penSkinID = renderer.createPenSkin();
renderer.updateDrawableProperties(drawableID2, {
skinId: penSkinID
});
canvas.addEventListener('click', event => {
const rect = canvas.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
renderer.penLine(penSkinID, {
color4f: [Math.random(), Math.random(), Math.random(), 1],
diameter: 8
},
x - 240, 180 - y, (Math.random() * 480) - 240, (Math.random() * 360) - 180);
});
}
let posX = 0;
let posY = 0;
let scaleX = 100;
let scaleY = 100;
let fudgeProperty = 'posx';
const fudgeInput = document.getElementById('fudge');
const fudgePropertyInput = document.getElementById('fudgeproperty');
const fudgeMinInput = document.getElementById('fudgeMin');
const fudgeMaxInput = document.getElementById('fudgeMax');
/* eslint require-jsdoc: 0 */
const updateFudgeProperty = event => {
fudgeProperty = event.target.value;
};
const updateFudgeMin = event => {
fudgeInput.min = event.target.valueAsNumber;
};
const updateFudgeMax = event => {
fudgeInput.max = event.target.valueAsNumber;
};
fudgePropertyInput.addEventListener('change', updateFudgeProperty);
fudgePropertyInput.addEventListener('init', updateFudgeProperty);
fudgeMinInput.addEventListener('change', updateFudgeMin);
fudgeMinInput.addEventListener('init', updateFudgeMin);
fudgeMaxInput.addEventListener('change', updateFudgeMax);
fudgeMaxInput.addEventListener('init', updateFudgeMax);
// Ugly hack to properly set the values of the inputs on page load,
// since they persist across reloads, at least in Firefox.
// The best ugly hacks are the ones that reduce code duplication!
fudgePropertyInput.dispatchEvent(new CustomEvent('init'));
fudgeMinInput.dispatchEvent(new CustomEvent('init'));
fudgeMaxInput.dispatchEvent(new CustomEvent('init'));
fudgeInput.dispatchEvent(new CustomEvent('init'));
const handleFudgeChanged = function (event) {
fudge = event.target.valueAsNumber;
const props = {};
switch (fudgeProperty) {
case 'posx':
props.position = [fudge, posY];
posX = fudge;
break;
case 'posy':
props.position = [posX, fudge];
posY = fudge;
break;
case 'direction':
props.direction = fudge;
break;
case 'scalex':
props.scale = [fudge, scaleY];
scaleX = fudge;
break;
case 'scaley':
props.scale = [scaleX, fudge];
scaleY = fudge;
break;
case 'scaleboth':
props.scale = [fudge, fudge];
scaleX = fudge;
scaleY = fudge;
break;
case 'color':
props.color = fudge;
break;
case 'whirl':
props.whirl = fudge;
break;
case 'fisheye':
props.fisheye = fudge;
break;
case 'pixelate':
props.pixelate = fudge;
break;
case 'mosaic':
props.mosaic = fudge;
break;
case 'brightness':
props.brightness = fudge;
break;
case 'ghost':
props.ghost = fudge;
break;
}
renderer.updateDrawableProperties(drawableID2, props);
};
fudgeInput.addEventListener('input', handleFudgeChanged);
fudgeInput.addEventListener('change', handleFudgeChanged);
fudgeInput.addEventListener('init', handleFudgeChanged);
const updateStageScale = event => {
renderer.resize(480 * event.target.valueAsNumber, 360 * event.target.valueAsNumber);
};
const stageScaleInput = document.getElementById('stage-scale');
stageScaleInput.addEventListener('input', updateStageScale);
stageScaleInput.addEventListener('change', updateStageScale);
canvas.addEventListener('mousemove', event => {
const mousePos = getMousePosition(event, canvas);
renderer.extractColor(mousePos.x, mousePos.y, 30);
});
canvas.addEventListener('click', event => {
const mousePos = getMousePosition(event, canvas);
const pickID = renderer.pick(mousePos.x, mousePos.y);
console.log(`You clicked on ${(pickID < 0 ? 'nothing' : `ID# ${pickID}`)}`);
if (pickID >= 0) {
console.dir(renderer.extractDrawableScreenSpace(pickID, mousePos.x, mousePos.y));
}
});
const drawStep = function () {
renderer.draw();
// renderer.getBounds(drawableID2);
// renderer.isTouchingColor(drawableID2, [255,255,255]);
requestAnimationFrame(drawStep);
};
drawStep();
const debugCanvas = /** @type {canvas} */ document.getElementById('debug-canvas');
renderer.setDebugCanvas(debugCanvas);

View File

@@ -0,0 +1,73 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Scratch WebGL Query Playground</title>
<link rel="stylesheet" type="text/css" href="style.css">
<style>
input[type=range][orient=vertical] {
writing-mode: bt-lr; /* IE */
-webkit-appearance: slider-vertical;
width: 1rem;
padding: 0 0.5rem;
}
canvas {
/* https://stackoverflow.com/a/7665647 */
image-rendering: optimizeSpeed; /* Older versions of FF */
image-rendering: -moz-crisp-edges; /* FF 6.0+ */
image-rendering: -webkit-optimize-contrast; /* Safari */
image-rendering: -o-crisp-edges; /* OS X & Windows Opera (12.02+) */
image-rendering: pixelated; /* Awesome future-browsers */
-ms-interpolation-mode: nearest-neighbor; /* IE */
}
</style>
</head>
<body>
<div>
<fieldset>
<legend>Query Canvases</legend>
<table>
<tr>
<td>
<fieldset>
<legend>GPU</legend>
<div>Touching color A? <span id="gpuTouchingA">maybe</span></div>
<div>Touching color B? <span id="gpuTouchingB">maybe</span></div>
<canvas id="gpuQueryCanvas" width="480" height="360" style="height: 20rem"></canvas>
</fieldset>
</td>
<td>
<fieldset>
<legend>CPU</legend>
<div>Touching color A? <span id="cpuTouchingA">maybe</span></div>
<div>Touching color B? <span id="cpuTouchingB">maybe</span></div>
<canvas id="cpuQueryCanvas" width="480" height="360" style="height: 20rem"></canvas>
</fieldset>
</td>
</tr>
</table>
</fieldset>
<fieldset>
<legend>Render Canvas</legend>
<div>Cursor Position: <span id="cursorPosition">somewhere</span></div>
<table>
<tr>
<td></td>
<td>
<input id="cursorX" type="range" step="0.25" value="0" />
</td>
</tr>
<tr>
<td>
<input id="cursorY" type="range" orient="vertical" step="0.25" value="0" />
</td>
<td>
<canvas id="renderCanvas" width="480" height="360"></canvas>
</td>
</tr>
</table>
</fieldset>
</div>
</body>
<script src="queryPlayground.js"></script>
</html>

View File

@@ -0,0 +1,196 @@
const ScratchRender = require('../RenderWebGL');
const getMousePosition = require('./getMousePosition');
const renderCanvas = document.getElementById('renderCanvas');
const gpuQueryCanvas = document.getElementById('gpuQueryCanvas');
const cpuQueryCanvas = document.getElementById('cpuQueryCanvas');
const inputCursorX = document.getElementById('cursorX');
const inputCursorY = document.getElementById('cursorY');
const labelCursorPosition = document.getElementById('cursorPosition');
const labelGpuTouchingA = document.getElementById('gpuTouchingA');
const labelGpuTouchingB = document.getElementById('gpuTouchingB');
const labelCpuTouchingA = document.getElementById('cpuTouchingA');
const labelCpuTouchingB = document.getElementById('cpuTouchingB');
const drawables = {
testPattern: -1,
cursor: -1
};
const colors = {
cursor: [255, 0, 0],
patternA: [0, 255, 0],
patternB: [0, 0, 255]
};
const renderer = new ScratchRender(renderCanvas);
const handleResizeRenderCanvas = () => {
const halfWidth = renderCanvas.clientWidth / 2;
const halfHeight = renderCanvas.clientHeight / 2;
inputCursorX.style.width = `${renderCanvas.clientWidth}px`;
inputCursorY.style.height = `${renderCanvas.clientHeight}px`;
inputCursorX.min = -halfWidth;
inputCursorX.max = halfWidth;
inputCursorY.min = -halfHeight;
inputCursorY.max = halfHeight;
};
renderCanvas.addEventListener('resize', handleResizeRenderCanvas);
handleResizeRenderCanvas();
const handleCursorPositionChanged = () => {
const devicePixelRatio = window.devicePixelRatio || 1;
const cursorX = inputCursorX.valueAsNumber / devicePixelRatio;
const cursorY = inputCursorY.valueAsNumber / devicePixelRatio;
const positionHTML = `${cursorX}, ${cursorY}`;
labelCursorPosition.innerHTML = positionHTML;
if (drawables.cursor >= 0) {
renderer.draw();
renderer.updateDrawableProperties(drawables.cursor, {
position: [cursorX, cursorY]
});
renderer.setUseGpuMode(ScratchRender.UseGpuModes.ForceGPU);
renderer.setDebugCanvas(gpuQueryCanvas);
const isGpuTouchingA = renderer.isTouchingColor(drawables.cursor, colors.patternA);
const isGpuTouchingB = renderer.isTouchingColor(drawables.cursor, colors.patternB);
labelGpuTouchingA.innerHTML = isGpuTouchingA ? 'yes' : 'no';
labelGpuTouchingB.innerHTML = isGpuTouchingB ? 'yes' : 'no';
renderer.setUseGpuMode(ScratchRender.UseGpuModes.ForceCPU);
renderer.setDebugCanvas(cpuQueryCanvas);
const isCpuTouchingA = renderer.isTouchingColor(drawables.cursor, colors.patternA);
const isCpuTouchingB = renderer.isTouchingColor(drawables.cursor, colors.patternB);
labelCpuTouchingA.innerHTML = isCpuTouchingA ? 'yes' : 'no';
labelCpuTouchingB.innerHTML = isCpuTouchingB ? 'yes' : 'no';
renderer.setUseGpuMode(ScratchRender.UseGpuModes.Automatic);
}
};
inputCursorX.addEventListener('change', handleCursorPositionChanged);
inputCursorY.addEventListener('change', handleCursorPositionChanged);
inputCursorX.addEventListener('input', handleCursorPositionChanged);
inputCursorY.addEventListener('input', handleCursorPositionChanged);
handleCursorPositionChanged();
let trackingMouse = true;
const handleMouseMove = event => {
if (trackingMouse) {
const mousePosition = getMousePosition(event, renderCanvas);
inputCursorX.value = mousePosition.x - (renderCanvas.clientWidth / 2);
inputCursorY.value = (renderCanvas.clientHeight / 2) - mousePosition.y;
handleCursorPositionChanged();
}
};
renderCanvas.addEventListener('mousemove', handleMouseMove);
renderCanvas.addEventListener('click', event => {
trackingMouse = !trackingMouse;
if (trackingMouse) {
handleMouseMove(event);
}
});
const rgb2fillStyle = rgb => (
`rgb(${rgb[0]},${rgb[1]},${rgb[2]})`
);
const makeCursorImage = () => {
const canvas = document.createElement('canvas');
canvas.width = canvas.height = 1;
const context = canvas.getContext('2d');
context.fillStyle = rgb2fillStyle(colors.cursor);
context.fillRect(0, 0, 1, 1);
return canvas;
};
const makeTestPatternImage = () => {
const canvas = document.createElement('canvas');
canvas.width = 480;
canvas.height = 360;
const patternA = rgb2fillStyle(colors.patternA);
const patternB = rgb2fillStyle(colors.patternB);
const context = canvas.getContext('2d');
context.fillStyle = patternA;
context.fillRect(0, 0, canvas.width, canvas.height);
context.fillStyle = patternB;
const xSplit1 = Math.floor(canvas.width * 0.25);
const xSplit2 = Math.floor(canvas.width * 0.5);
const xSplit3 = Math.floor(canvas.width * 0.75);
const ySplit = Math.floor(canvas.height * 0.5);
for (let y = 0; y < ySplit; y += 2) {
context.fillRect(0, y, xSplit2, 1);
}
for (let x = xSplit2; x < canvas.width; x += 2) {
context.fillRect(x, 0, 1, ySplit);
}
for (let x = 0; x < xSplit1; x += 2) {
for (let y = ySplit; y < canvas.height; y += 2) {
context.fillRect(x, y, 1, 1);
}
}
for (let x = xSplit1; x < xSplit2; x += 3) {
for (let y = ySplit; y < canvas.height; y += 3) {
context.fillRect(x, y, 2, 2);
}
}
for (let x = xSplit2; x < xSplit3; ++x) {
for (let y = ySplit; y < canvas.height; ++y) {
context.fillStyle = (x + y) % 2 ? patternB : patternA;
context.fillRect(x, y, 1, 1);
}
}
for (let x = xSplit3; x < canvas.width; x += 2) {
for (let y = ySplit; y < canvas.height; y += 2) {
context.fillStyle = (x + y) % 4 ? patternB : patternA;
context.fillRect(x, y, 2, 2);
}
}
return canvas;
};
const makeTestPatternDrawable = function (group) {
const image = makeTestPatternImage();
const skinId = renderer.createBitmapSkin(image, 1);
const drawableId = renderer.createDrawable(group);
renderer.updateDrawableProperties(drawableId, {skinId});
return drawableId;
};
const makeCursorDrawable = function (group) {
const image = makeCursorImage();
const skinId = renderer.createBitmapSkin(image, 1, [0, 0]);
const drawableId = renderer.createDrawable(group);
renderer.updateDrawableProperties(drawableId, {skinId});
return drawableId;
};
const initRendering = () => {
const layerGroup = {
testPattern: 'testPattern',
cursor: 'cursor'
};
renderer.setLayerGroupOrdering([layerGroup.testPattern, layerGroup.cursor]);
drawables.testPattern = makeTestPatternDrawable(layerGroup.testPattern);
drawables.cursor = makeCursorDrawable(layerGroup.cursor);
const corner00 = makeCursorDrawable(layerGroup.cursor);
const corner01 = makeCursorDrawable(layerGroup.cursor);
const corner10 = makeCursorDrawable(layerGroup.cursor);
const corner11 = makeCursorDrawable(layerGroup.cursor);
renderer.updateDrawableProperties(corner00, {position: [-240, -179]});
renderer.updateDrawableProperties(corner01, {position: [-240, 180]});
renderer.updateDrawableProperties(corner10, {position: [239, -179]});
renderer.updateDrawableProperties(corner11, {position: [239, 180]});
};
initRendering();
renderer.draw();

View File

@@ -0,0 +1,11 @@
body {
background: lightsteelblue;
}
canvas {
border: 3px dashed black;
}
#debug-canvas {
border-color: red;
}

View File

@@ -0,0 +1,12 @@
.scratch-render-overlays {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
overflow: hidden;
}
.scratch-render-overlays > * {
position: absolute;
top: 0;
left: 0;
}

View File

@@ -0,0 +1,249 @@
precision mediump float;
#ifdef DRAW_MODE_silhouette
uniform vec4 u_silhouetteColor;
#else // DRAW_MODE_silhouette
# ifdef ENABLE_color
uniform float u_color;
# endif // ENABLE_color
# ifdef ENABLE_brightness
uniform float u_brightness;
# endif // ENABLE_brightness
#endif // DRAW_MODE_silhouette
#ifdef DRAW_MODE_colorMask
uniform vec3 u_colorMask;
uniform float u_colorMaskTolerance;
#endif // DRAW_MODE_colorMask
#ifdef ENABLE_fisheye
uniform float u_fisheye;
#endif // ENABLE_fisheye
#ifdef ENABLE_whirl
uniform float u_whirl;
#endif // ENABLE_whirl
#ifdef ENABLE_pixelate
uniform float u_pixelate;
uniform vec2 u_skinSize;
#endif // ENABLE_pixelate
#ifdef ENABLE_mosaic
uniform float u_mosaic;
#endif // ENABLE_mosaic
#ifdef ENABLE_ghost
uniform float u_ghost;
#endif // ENABLE_ghost
#ifdef DRAW_MODE_line
varying vec4 v_lineColor;
varying float v_lineThickness;
varying float v_lineLength;
#endif // DRAW_MODE_line
#ifdef DRAW_MODE_background
uniform vec4 u_backgroundColor;
#endif // DRAW_MODE_background
uniform sampler2D u_skin;
#ifndef DRAW_MODE_background
varying vec2 v_texCoord;
#endif
// Add this to divisors to prevent division by 0, which results in NaNs propagating through calculations.
// Smaller values can cause problems on some mobile devices.
const float epsilon = 1e-3;
#if !defined(DRAW_MODE_silhouette) && (defined(ENABLE_color))
// Branchless color conversions based on code from:
// http://www.chilliant.com/rgb2hsv.html by Ian Taylor
// Based in part on work by Sam Hocevar and Emil Persson
// See also: https://en.wikipedia.org/wiki/HSL_and_HSV#Formal_derivation
// Convert an RGB color to Hue, Saturation, and Value.
// All components of input and output are expected to be in the [0,1] range.
vec3 convertRGB2HSV(vec3 rgb)
{
// Hue calculation has 3 cases, depending on which RGB component is largest, and one of those cases involves a "mod"
// operation. In order to avoid that "mod" we split the M==R case in two: one for G<B and one for B>G. The B>G case
// will be calculated in the negative and fed through abs() in the hue calculation at the end.
// See also: https://en.wikipedia.org/wiki/HSL_and_HSV#Hue_and_chroma
const vec4 hueOffsets = vec4(0.0, -1.0 / 3.0, 2.0 / 3.0, -1.0);
// temp1.xy = sort B & G (largest first)
// temp1.z = the hue offset we'll use if it turns out that R is the largest component (M==R)
// temp1.w = the hue offset we'll use if it turns out that R is not the largest component (M==G or M==B)
vec4 temp1 = rgb.b > rgb.g ? vec4(rgb.bg, hueOffsets.wz) : vec4(rgb.gb, hueOffsets.xy);
// temp2.x = the largest component of RGB ("M" / "Max")
// temp2.yw = the smaller components of RGB, ordered for the hue calculation (not necessarily sorted by magnitude!)
// temp2.z = the hue offset we'll use in the hue calculation
vec4 temp2 = rgb.r > temp1.x ? vec4(rgb.r, temp1.yzx) : vec4(temp1.xyw, rgb.r);
// m = the smallest component of RGB ("min")
float m = min(temp2.y, temp2.w);
// Chroma = M - m
float C = temp2.x - m;
// Value = M
float V = temp2.x;
return vec3(
abs(temp2.z + (temp2.w - temp2.y) / (6.0 * C + epsilon)), // Hue
C / (temp2.x + epsilon), // Saturation
V); // Value
}
vec3 convertHue2RGB(float hue)
{
float r = abs(hue * 6.0 - 3.0) - 1.0;
float g = 2.0 - abs(hue * 6.0 - 2.0);
float b = 2.0 - abs(hue * 6.0 - 4.0);
return clamp(vec3(r, g, b), 0.0, 1.0);
}
vec3 convertHSV2RGB(vec3 hsv)
{
vec3 rgb = convertHue2RGB(hsv.x);
float c = hsv.z * hsv.y;
return rgb * c + hsv.z - c;
}
#endif // !defined(DRAW_MODE_silhouette) && (defined(ENABLE_color))
const vec2 kCenter = vec2(0.5, 0.5);
void main()
{
#if !(defined(DRAW_MODE_line) || defined(DRAW_MODE_background))
vec2 texcoord0 = v_texCoord;
#ifdef ENABLE_mosaic
texcoord0 = fract(u_mosaic * texcoord0);
#endif // ENABLE_mosaic
#ifdef ENABLE_pixelate
{
// TODO: clean up "pixel" edges
vec2 pixelTexelSize = u_skinSize / u_pixelate;
texcoord0 = (floor(texcoord0 * pixelTexelSize) + kCenter) / pixelTexelSize;
}
#endif // ENABLE_pixelate
#ifdef ENABLE_whirl
{
const float kRadius = 0.5;
vec2 offset = texcoord0 - kCenter;
float offsetMagnitude = length(offset);
float whirlFactor = max(1.0 - (offsetMagnitude / kRadius), 0.0);
float whirlActual = u_whirl * whirlFactor * whirlFactor;
float sinWhirl = sin(whirlActual);
float cosWhirl = cos(whirlActual);
mat2 rotationMatrix = mat2(
cosWhirl, -sinWhirl,
sinWhirl, cosWhirl
);
texcoord0 = rotationMatrix * offset + kCenter;
}
#endif // ENABLE_whirl
#ifdef ENABLE_fisheye
{
vec2 vec = (texcoord0 - kCenter) / kCenter;
float vecLength = length(vec);
float r = pow(min(vecLength, 1.0), u_fisheye) * max(1.0, vecLength);
vec2 unit = vec / vecLength;
texcoord0 = kCenter + r * unit * kCenter;
}
#endif // ENABLE_fisheye
gl_FragColor = texture2D(u_skin, texcoord0);
#if defined(ENABLE_color) || defined(ENABLE_brightness)
// Divide premultiplied alpha values for proper color processing
// Add epsilon to avoid dividing by 0 for fully transparent pixels
gl_FragColor.rgb = clamp(gl_FragColor.rgb / (gl_FragColor.a + epsilon), 0.0, 1.0);
#ifdef ENABLE_color
{
vec3 hsv = convertRGB2HSV(gl_FragColor.xyz);
// this code forces grayscale values to be slightly saturated
// so that some slight change of hue will be visible
const float minLightness = 0.11 / 2.0;
const float minSaturation = 0.09;
if (hsv.z < minLightness) hsv = vec3(0.0, 1.0, minLightness);
else if (hsv.y < minSaturation) hsv = vec3(0.0, minSaturation, hsv.z);
hsv.x = mod(hsv.x + u_color, 1.0);
if (hsv.x < 0.0) hsv.x += 1.0;
gl_FragColor.rgb = convertHSV2RGB(hsv);
}
#endif // ENABLE_color
#ifdef ENABLE_brightness
gl_FragColor.rgb = clamp(gl_FragColor.rgb + vec3(u_brightness), vec3(0), vec3(1));
#endif // ENABLE_brightness
// Re-multiply color values
gl_FragColor.rgb *= gl_FragColor.a + epsilon;
#endif // defined(ENABLE_color) || defined(ENABLE_brightness)
#ifdef ENABLE_ghost
gl_FragColor *= u_ghost;
#endif // ENABLE_ghost
#ifdef DRAW_MODE_silhouette
// Discard fully transparent pixels for stencil test
if (gl_FragColor.a == 0.0) {
discard;
}
// switch to u_silhouetteColor only AFTER the alpha test
gl_FragColor = u_silhouetteColor;
#else // DRAW_MODE_silhouette
#ifdef DRAW_MODE_colorMask
vec3 maskDistance = abs(gl_FragColor.rgb - u_colorMask);
vec3 colorMaskTolerance = vec3(u_colorMaskTolerance, u_colorMaskTolerance, u_colorMaskTolerance);
if (any(greaterThan(maskDistance, colorMaskTolerance)))
{
discard;
}
#endif // DRAW_MODE_colorMask
#endif // DRAW_MODE_silhouette
#ifdef DRAW_MODE_straightAlpha
// Un-premultiply alpha.
gl_FragColor.rgb /= gl_FragColor.a + epsilon;
#endif
#endif // !(defined(DRAW_MODE_line) || defined(DRAW_MODE_background))
#ifdef DRAW_MODE_line
// Maaaaagic antialiased-line-with-round-caps shader.
// "along-the-lineness". This increases parallel to the line.
// It goes from negative before the start point, to 0.5 through the start to the end, then ramps up again
// past the end point.
float d = ((v_texCoord.x - clamp(v_texCoord.x, 0.0, v_lineLength)) * 0.5) + 0.5;
// Distance from (0.5, 0.5) to (d, the perpendicular coordinate). When we're in the middle of the line,
// d will be 0.5, so the distance will be 0 at points close to the line and will grow at points further from it.
// For the "caps", d will ramp down/up, giving us rounding.
// See https://www.youtube.com/watch?v=PMltMdi1Wzg for a rough outline of the technique used to round the lines.
float line = distance(vec2(0.5), vec2(d, v_texCoord.y)) * 2.0;
// Expand out the line by its thickness.
line -= ((v_lineThickness - 1.0) * 0.5);
// Because "distance to the center of the line" decreases the closer we get to the line, but we want more opacity
// the closer we are to the line, invert it.
gl_FragColor = v_lineColor * clamp(1.0 - line, 0.0, 1.0);
#endif // DRAW_MODE_line
#ifdef DRAW_MODE_background
gl_FragColor = u_backgroundColor;
#endif
}

View File

@@ -0,0 +1,82 @@
precision mediump float;
#ifdef DRAW_MODE_line
uniform vec2 u_stageSize;
attribute vec2 a_lineThicknessAndLength;
attribute vec4 a_penPoints;
attribute vec4 a_lineColor;
varying vec4 v_lineColor;
varying float v_lineThickness;
varying float v_lineLength;
varying vec4 v_penPoints;
// Add this to divisors to prevent division by 0, which results in NaNs propagating through calculations.
// Smaller values can cause problems on some mobile devices.
const float epsilon = 1e-3;
#endif
#if !(defined(DRAW_MODE_line) || defined(DRAW_MODE_background))
uniform mat4 u_projectionMatrix;
uniform mat4 u_modelMatrix;
attribute vec2 a_texCoord;
#endif
attribute vec2 a_position;
varying vec2 v_texCoord;
void main() {
#ifdef DRAW_MODE_line
// Calculate a rotated ("tight") bounding box around the two pen points.
// Yes, we're doing this 6 times (once per vertex), but on actual GPU hardware,
// it's still faster than doing it in JS combined with the cost of uniformMatrix4fv.
// Expand line bounds by sqrt(2) / 2 each side-- this ensures that all antialiased pixels
// fall within the quad, even at a 45-degree diagonal
vec2 position = a_position;
float expandedRadius = (a_lineThicknessAndLength.x * 0.5) + 1.4142135623730951;
// The X coordinate increases along the length of the line. It's 0 at the center of the origin point
// and is in pixel-space (so at n pixels along the line, its value is n).
v_texCoord.x = mix(0.0, a_lineThicknessAndLength.y + (expandedRadius * 2.0), a_position.x) - expandedRadius;
// The Y coordinate is perpendicular to the line. It's also in pixel-space.
v_texCoord.y = ((a_position.y - 0.5) * expandedRadius) + 0.5;
position.x *= a_lineThicknessAndLength.y + (2.0 * expandedRadius);
position.y *= 2.0 * expandedRadius;
// 1. Center around first pen point
position -= expandedRadius;
// 2. Rotate quad to line angle
vec2 pointDiff = a_penPoints.zw;
// Ensure line has a nonzero length so it's rendered properly
// As long as either component is nonzero, the line length will be nonzero
// If the line is zero-length, give it a bit of horizontal length
pointDiff.x = (abs(pointDiff.x) < epsilon && abs(pointDiff.y) < epsilon) ? epsilon : pointDiff.x;
// The `normalized` vector holds rotational values equivalent to sine/cosine
// We're applying the standard rotation matrix formula to the position to rotate the quad to the line angle
// pointDiff can hold large values so we must divide by u_lineLength instead of calling GLSL's normalize function:
// https://asawicki.info/news_1596_watch_out_for_reduced_precision_normalizelength_in_opengl_es
vec2 normalized = pointDiff / max(a_lineThicknessAndLength.y, epsilon);
position = mat2(normalized.x, normalized.y, -normalized.y, normalized.x) * position;
// 3. Translate quad
position += a_penPoints.xy;
// 4. Apply view transform
position *= 2.0 / u_stageSize;
gl_Position = vec4(position, 0, 1);
v_lineColor = a_lineColor;
v_lineThickness = a_lineThicknessAndLength.x;
v_lineLength = a_lineThicknessAndLength.y;
v_penPoints = a_penPoints;
#elif defined(DRAW_MODE_background)
gl_Position = vec4(a_position * 2.0, 0, 1);
#else
gl_Position = u_projectionMatrix * u_modelMatrix * vec4(a_position, 0, 1);
v_texCoord = a_texCoord;
#endif
}

View File

@@ -0,0 +1,41 @@
class CanvasMeasurementProvider {
/**
* @param {CanvasRenderingContext2D} ctx - provides a canvas rendering context
* with 'font' set to the text style of the text to be wrapped.
*/
constructor (ctx) {
this._ctx = ctx;
this._cache = {};
}
// We don't need to set up or tear down anything here. Should these be removed altogether?
/**
* Called by the TextWrapper before a batch of zero or more calls to measureText().
*/
beginMeasurementSession () {
}
/**
* Called by the TextWrapper after a batch of zero or more calls to measureText().
*/
endMeasurementSession () {
}
/**
* Measure a whole string as one unit.
* @param {string} text - the text to measure.
* @returns {number} - the length of the string.
*/
measureText (text) {
if (!this._cache[text]) {
this._cache[text] = this._ctx.measureText(text).width;
}
return this._cache[text];
}
}
module.exports = CanvasMeasurementProvider;

View File

@@ -0,0 +1,97 @@
/**
* Converts an RGB color value to HSV. Conversion formula
* adapted from http://lolengine.net/blog/2013/01/13/fast-rgb-to-hsv.
* Assumes r, g, and b are in the range [0, 255] and
* returns h, s, and v in the range [0, 1].
*
* @param {Array<number>} rgb The RGB color value
* @param {number} rgb.r The red color value
* @param {number} rgb.g The green color value
* @param {number} rgb.b The blue color value
* @param {Array<number>} dst The array to store the HSV values in
* @return {Array<number>} The `dst` array passed in
*/
const rgbToHsv = ([r, g, b], dst) => {
let K = 0.0;
r /= 255;
g /= 255;
b /= 255;
let tmp = 0;
if (g < b) {
tmp = g;
g = b;
b = tmp;
K = -1;
}
if (r < g) {
tmp = r;
r = g;
g = tmp;
K = (-2 / 6) - K;
}
const chroma = r - Math.min(g, b);
const h = Math.abs(K + ((g - b) / ((6 * chroma) + Number.EPSILON)));
const s = chroma / (r + Number.EPSILON);
const v = r;
dst[0] = h;
dst[1] = s;
dst[2] = v;
return dst;
};
/**
* Converts an HSV color value to RGB. Conversion formula
* adapted from https://gist.github.com/mjackson/5311256.
* Assumes h, s, and v are contained in the set [0, 1] and
* returns r, g, and b in the set [0, 255].
*
* @param {Array<number>} hsv The HSV color value
* @param {number} hsv.h The hue
* @param {number} hsv.s The saturation
* @param {number} hsv.v The value
* @param {Uint8Array|Uint8ClampedArray} dst The array to store the RGB values in
* @return {Uint8Array|Uint8ClampedArray} The `dst` array passed in
*/
const hsvToRgb = ([h, s, v], dst) => {
if (s === 0) {
dst[0] = dst[1] = dst[2] = (v * 255) + 0.5;
return dst;
}
// keep hue in [0,1) so the `switch(i)` below only needs 6 cases (0-5)
h %= 1;
const i = (h * 6) | 0;
const f = (h * 6) - i;
const p = v * (1 - s);
const q = v * (1 - (s * f));
const t = v * (1 - (s * (1 - f)));
let r = 0;
let g = 0;
let b = 0;
switch (i) {
case 0: r = v; g = t; b = p; break;
case 1: r = q; g = v; b = p; break;
case 2: r = p; g = v; b = t; break;
case 3: r = p; g = q; b = v; break;
case 4: r = t; g = p; b = v; break;
case 5: r = v; g = p; b = q; break;
}
// Add 0.5 in order to round. Setting integer TypedArray elements implicitly floors.
dst[0] = (r * 255) + 0.5;
dst[1] = (g * 255) + 0.5;
dst[2] = (b * 255) + 0.5;
return dst;
};
module.exports = {rgbToHsv, hsvToRgb};

View File

@@ -0,0 +1,4 @@
const nanolog = require('@turbowarp/nanolog');
nanolog.enable();
module.exports = nanolog('scratch-render');

View File

@@ -0,0 +1,112 @@
const LineBreaker = require('!ify-loader!linebreak');
const GraphemeBreaker = require('!ify-loader!grapheme-breaker');
/**
* Tell this text wrapper to use a specific measurement provider.
* @typedef {object} MeasurementProvider - the new measurement provider.
* @property {Function} beginMeasurementSession - this will be called before a batch of measurements are made.
* Optionally, this function may return an object to be provided to the endMeasurementSession function.
* @property {Function} measureText - this will be called each time a piece of text must be measured.
* @property {Function} endMeasurementSession - this will be called after a batch of measurements is finished.
* It will be passed whatever value beginMeasurementSession returned, if any.
*/
/**
* Utility to wrap text across several lines, respecting Unicode grapheme clusters and, when possible, Unicode line
* break opportunities.
* Reference material:
* - Unicode Standard Annex #14: http://unicode.org/reports/tr14/
* - Unicode Standard Annex #29: http://unicode.org/reports/tr29/
* - "JavaScript has a Unicode problem" by Mathias Bynens: https://mathiasbynens.be/notes/javascript-unicode
*/
class TextWrapper {
/**
* Construct a text wrapper which will measure text using the specified measurement provider.
* @param {MeasurementProvider} measurementProvider - a helper object to provide text measurement services.
*/
constructor (measurementProvider) {
this._measurementProvider = measurementProvider;
this._cache = {};
}
/**
* Wrap the provided text into lines restricted to a maximum width. See Unicode Standard Annex (UAX) #14.
* @param {number} maxWidth - the maximum allowed width of a line.
* @param {string} text - the text to be wrapped. Will be split on whitespace.
* @returns {Array.<string>} an array containing the wrapped lines of text.
*/
wrapText (maxWidth, text) {
// Normalize to canonical composition (see Unicode Standard Annex (UAX) #15)
text = text.normalize();
const cacheKey = `${maxWidth}-${text}`;
if (this._cache[cacheKey]) {
return this._cache[cacheKey];
}
const measurementSession = this._measurementProvider.beginMeasurementSession();
const breaker = new LineBreaker(text);
let lastPosition = 0;
let nextBreak;
let currentLine = null;
const lines = [];
while ((nextBreak = breaker.nextBreak())) {
const word = text.slice(lastPosition, nextBreak.position).replace(/\n+$/, '');
let proposedLine = (currentLine || '').concat(word);
let proposedLineWidth = this._measurementProvider.measureText(proposedLine);
if (proposedLineWidth > maxWidth) {
// The next word won't fit on this line. Will it fit on a line by itself?
const wordWidth = this._measurementProvider.measureText(word);
if (wordWidth > maxWidth) {
// The next word can't even fit on a line by itself. Consume it one grapheme cluster at a time.
let lastCluster = 0;
let nextCluster;
while (lastCluster !== (nextCluster = GraphemeBreaker.nextBreak(word, lastCluster))) {
const cluster = word.substring(lastCluster, nextCluster);
proposedLine = (currentLine || '').concat(cluster);
proposedLineWidth = this._measurementProvider.measureText(proposedLine);
if ((currentLine === null) || (proposedLineWidth <= maxWidth)) {
// first cluster of a new line or the cluster fits
currentLine = proposedLine;
} else {
// no more can fit
lines.push(currentLine);
currentLine = cluster;
}
lastCluster = nextCluster;
}
} else {
// The next word can fit on the next line. Finish the current line and move on.
if (currentLine !== null) lines.push(currentLine);
currentLine = word;
}
} else {
// The next word fits on this line. Just keep going.
currentLine = proposedLine;
}
// Did we find a \n or similar?
if (nextBreak.required) {
if (currentLine !== null) lines.push(currentLine);
currentLine = null;
}
lastPosition = nextBreak.position;
}
currentLine = currentLine || '';
if (currentLine.length > 0 || lines.length === 0) {
lines.push(currentLine);
}
this._cache[cacheKey] = lines;
this._measurementProvider.endMeasurementSession(measurementSession);
return lines;
}
}
module.exports = TextWrapper;