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:
11
scratch-render/src/.eslintrc.js
Normal file
11
scratch-render/src/.eslintrc.js
Normal 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?
|
||||
}
|
||||
};
|
||||
116
scratch-render/src/BitmapSkin.js
Normal file
116
scratch-render/src/BitmapSkin.js
Normal 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;
|
||||
745
scratch-render/src/Drawable.js
Normal file
745
scratch-render/src/Drawable.js
Normal 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;
|
||||
197
scratch-render/src/EffectTransform.js
Normal file
197
scratch-render/src/EffectTransform.js
Normal 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;
|
||||
565
scratch-render/src/PenSkin.js
Normal file
565
scratch-render/src/PenSkin.js
Normal 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;
|
||||
196
scratch-render/src/Rectangle.js
Normal file
196
scratch-render/src/Rectangle.js
Normal 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;
|
||||
37
scratch-render/src/RenderConstants.js
Normal file
37
scratch-render/src/RenderConstants.js
Normal 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'
|
||||
}
|
||||
};
|
||||
2389
scratch-render/src/RenderWebGL.js
Normal file
2389
scratch-render/src/RenderWebGL.js
Normal file
File diff suppressed because it is too large
Load Diff
245
scratch-render/src/SVGSkin.js
Normal file
245
scratch-render/src/SVGSkin.js
Normal 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;
|
||||
202
scratch-render/src/ShaderManager.js
Normal file
202
scratch-render/src/ShaderManager.js
Normal 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;
|
||||
280
scratch-render/src/Silhouette.js
Normal file
280
scratch-render/src/Silhouette.js
Normal 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
231
scratch-render/src/Skin.js
Normal 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;
|
||||
282
scratch-render/src/TextBubbleSkin.js
Normal file
282
scratch-render/src/TextBubbleSkin.js
Normal 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;
|
||||
7
scratch-render/src/index.js
Normal file
7
scratch-render/src/index.js
Normal file
@@ -0,0 +1,7 @@
|
||||
const RenderWebGL = require('./RenderWebGL');
|
||||
|
||||
/**
|
||||
* Export for NPM & Node.js
|
||||
* @type {RenderWebGL}
|
||||
*/
|
||||
module.exports = RenderWebGL;
|
||||
9
scratch-render/src/playground/.eslintrc.js
Normal file
9
scratch-render/src/playground/.eslintrc.js
Normal file
@@ -0,0 +1,9 @@
|
||||
module.exports = {
|
||||
extends: ['scratch'],
|
||||
env: {
|
||||
browser: true
|
||||
},
|
||||
rules: {
|
||||
'no-console': 'off'
|
||||
}
|
||||
};
|
||||
37
scratch-render/src/playground/getMousePosition.js
Normal file
37
scratch-render/src/playground/getMousePosition.js
Normal 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;
|
||||
41
scratch-render/src/playground/index.html
Normal file
41
scratch-render/src/playground/index.html
Normal 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>
|
||||
202
scratch-render/src/playground/playground.js
Normal file
202
scratch-render/src/playground/playground.js
Normal 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);
|
||||
73
scratch-render/src/playground/queryPlayground.html
Normal file
73
scratch-render/src/playground/queryPlayground.html
Normal 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>
|
||||
196
scratch-render/src/playground/queryPlayground.js
Normal file
196
scratch-render/src/playground/queryPlayground.js
Normal 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();
|
||||
11
scratch-render/src/playground/style.css
Normal file
11
scratch-render/src/playground/style.css
Normal file
@@ -0,0 +1,11 @@
|
||||
body {
|
||||
background: lightsteelblue;
|
||||
}
|
||||
|
||||
canvas {
|
||||
border: 3px dashed black;
|
||||
}
|
||||
|
||||
#debug-canvas {
|
||||
border-color: red;
|
||||
}
|
||||
12
scratch-render/src/renderer.css
Normal file
12
scratch-render/src/renderer.css
Normal 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;
|
||||
}
|
||||
249
scratch-render/src/shaders/sprite.frag
Normal file
249
scratch-render/src/shaders/sprite.frag
Normal 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
|
||||
}
|
||||
82
scratch-render/src/shaders/sprite.vert
Normal file
82
scratch-render/src/shaders/sprite.vert
Normal 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
|
||||
}
|
||||
41
scratch-render/src/util/canvas-measurement-provider.js
Normal file
41
scratch-render/src/util/canvas-measurement-provider.js
Normal 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;
|
||||
97
scratch-render/src/util/color-conversions.js
Normal file
97
scratch-render/src/util/color-conversions.js
Normal 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};
|
||||
4
scratch-render/src/util/log.js
Normal file
4
scratch-render/src/util/log.js
Normal file
@@ -0,0 +1,4 @@
|
||||
const nanolog = require('@turbowarp/nanolog');
|
||||
nanolog.enable();
|
||||
|
||||
module.exports = nanolog('scratch-render');
|
||||
112
scratch-render/src/util/text-wrapper.js
Normal file
112
scratch-render/src/util/text-wrapper.js
Normal 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;
|
||||
Reference in New Issue
Block a user