mirror of
https://github.com/scratchfoundation/scratch-render.git
synced 2025-07-09 21:24:16 -04:00
The `isTouchingColor` function now takes an optional mask parameter. If provided, only the parts of the Drawable which match the mask color will be used for the test. For example: ``` isTouchingColor(4, red, blue); ``` This means "are there any parts of Drawable #4 which are blue and are touching a red pixel on some other Drawable?"
507 lines
15 KiB
JavaScript
507 lines
15 KiB
JavaScript
var twgl = require('twgl.js');
|
|
var svgToImage = require('svg-to-image');
|
|
var xhr = require('xhr');
|
|
|
|
/**
|
|
* An object which can be drawn by the renderer.
|
|
* TODO: double-buffer all rendering state (position, skin, shader index, etc.)
|
|
* @param renderer The renderer which owns this Drawable.
|
|
* @param gl The OpenGL context.
|
|
* @constructor
|
|
*/
|
|
function Drawable(renderer, gl) {
|
|
this._id = Drawable._nextDrawable++;
|
|
Drawable._allDrawables[this._id] = this;
|
|
|
|
this._renderer = renderer;
|
|
this._gl = gl;
|
|
|
|
/**
|
|
* 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 the projection matrix at draw time.
|
|
* @type {module:twgl/m4.Mat4}
|
|
*/
|
|
u_modelMatrix: twgl.m4.identity(),
|
|
|
|
/**
|
|
* The nominal (not necessarily current) size of the current skin.
|
|
* This is scaled by _costumeResolution.
|
|
* @type {number[]}
|
|
*/
|
|
u_skinSize: [0, 0],
|
|
|
|
/**
|
|
* The actual WebGL texture object for the skin.
|
|
* @type {WebGLTexture}
|
|
*/
|
|
u_skin: null
|
|
};
|
|
|
|
// Effect values are uniforms too
|
|
var numEffects = Drawable.EFFECTS.length;
|
|
for (var index = 0; index < numEffects; ++index) {
|
|
var effectName = Drawable.EFFECTS[index];
|
|
var converter = Drawable._effectConverter[effectName];
|
|
this._uniforms['u_' + effectName] = converter(0);
|
|
}
|
|
|
|
this._position = twgl.v3.create(0, 0);
|
|
this._scale = 100;
|
|
this._direction = 90;
|
|
this._transformDirty = true;
|
|
this._shaderIndex = 0;
|
|
|
|
this.setSkin(Drawable._DEFAULT_SKIN);
|
|
}
|
|
|
|
module.exports = Drawable;
|
|
|
|
/**
|
|
* Mapping of each effect to a conversion function. The conversion function
|
|
* takes a Scratch value (generally in the range 0..100 or -100..100) and maps
|
|
* it to a value useful to the shader.
|
|
* @type {Object.<string,function>}
|
|
* @private
|
|
*/
|
|
Drawable._effectConverter = {
|
|
color: function(x) {
|
|
return (x / 200) % 1;
|
|
},
|
|
fisheye: function(x) {
|
|
return Math.max(0, (x + 100) / 100);
|
|
},
|
|
whirl: function(x) {
|
|
return x * Math.PI / 180;
|
|
},
|
|
pixelate: function(x) {
|
|
return Math.abs(x) / 10;
|
|
},
|
|
mosaic: function(x) {
|
|
x = Math.round((Math.abs(x) + 10) / 10);
|
|
// TODO: cap by Math.min(srcWidth, srcHeight)
|
|
return Math.max(1, Math.min(x, 512));
|
|
},
|
|
brightness: function(x) {
|
|
return Math.max(-100, Math.min(x, 100)) / 100;
|
|
},
|
|
ghost: function(x) {
|
|
return 1 - Math.max(0, Math.min(x, 100)) / 100;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* @callback Drawable~idFilterFunc
|
|
* @param {int} drawableID The ID to filter.
|
|
* @return {bool} True if the ID passes the filter, otherwise false.
|
|
*/
|
|
|
|
/**
|
|
* An invalid Drawable ID which can be used to signify absence, etc.
|
|
* @type {int}
|
|
*/
|
|
Drawable.NONE = -1;
|
|
|
|
/**
|
|
* The name of each supported effect.
|
|
* @type {Array}
|
|
*/
|
|
Drawable.EFFECTS = Object.keys(Drawable._effectConverter);
|
|
|
|
/**
|
|
* The available draw modes.
|
|
* @readonly
|
|
* @enum {string}
|
|
*/
|
|
Drawable.DRAW_MODE = {
|
|
/**
|
|
* Draw normally.
|
|
*/
|
|
default: 'default',
|
|
|
|
/**
|
|
* Draw the Drawable's silhouette using a particular color.
|
|
*/
|
|
silhouette: 'silhouette',
|
|
|
|
/**
|
|
* Draw only the parts of the drawable which match a particular color.
|
|
*/
|
|
colorMask: 'colorMask'
|
|
};
|
|
|
|
/**
|
|
* The cache of all shaders compiled so far. These are generated on demand.
|
|
* @type {Object.<Drawable.DRAW_MODE, Array.<module:twgl.ProgramInfo>>}
|
|
* @private
|
|
*/
|
|
Drawable._shaderCache = {};
|
|
|
|
/**
|
|
* The ID to be assigned next time the Drawable constructor is called.
|
|
* @type {number}
|
|
* @private
|
|
*/
|
|
Drawable._nextDrawable = 0;
|
|
|
|
/**
|
|
* All current Drawables, by ID.
|
|
* @type {Object.<int, Drawable>}
|
|
* @private
|
|
*/
|
|
Drawable._allDrawables = {};
|
|
|
|
/**
|
|
* Fetch a Drawable by its ID number.
|
|
* @param drawableID {int} The ID of the Drawable to fetch.
|
|
* @returns {?Drawable} The specified Drawable if found, otherwise null.
|
|
*/
|
|
Drawable.getDrawableByID = function (drawableID) {
|
|
return Drawable._allDrawables[drawableID];
|
|
};
|
|
|
|
// TODO: fall back on a built-in skin to protect against network problems
|
|
Drawable._DEFAULT_SKIN = {
|
|
squirrel: '7e24c99c1b853e52f8e7f9004416fa34.png',
|
|
bus: '66895930177178ea01d9e610917f8acf.png',
|
|
scratch_cat: '09dc888b0b7df19f70d81588ae73420e.svg',
|
|
gradient: 'a49ff276b9b8f997a1ae163992c2c145.png'
|
|
}.squirrel;
|
|
|
|
/**
|
|
* Dispose of this Drawable. Do not use it after calling this method.
|
|
*/
|
|
Drawable.prototype.dispose = function () {
|
|
this.setSkin(null);
|
|
if (this._id >= 0) {
|
|
delete Drawable[this._id];
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Mark this Drawable's transform as dirty.
|
|
* It will be recalculated next time it's needed.
|
|
*/
|
|
Drawable.prototype.setTransformDirty = function () {
|
|
this._transformDirty = true;
|
|
};
|
|
|
|
/**
|
|
* Retrieve the ID for this Drawable.
|
|
* @returns {number} The ID for this Drawable.
|
|
*/
|
|
Drawable.prototype.getID = function () {
|
|
return this._id;
|
|
};
|
|
|
|
/**
|
|
* Set this Drawable's skin.
|
|
* The Drawable will briefly use a 1x1 skin while waiting for the
|
|
* @param {string} skin_md5ext The MD5 and file extension of the skin.
|
|
*/
|
|
Drawable.prototype.setSkin = function (skin_md5ext) {
|
|
// TODO: cache Skins instead of loading each time. Ref count them?
|
|
// TODO: share Skins across Drawables - see also destroy()
|
|
if (skin_md5ext) {
|
|
var ext = skin_md5ext.substring(skin_md5ext.indexOf('.')+1);
|
|
switch (ext) {
|
|
case 'svg':
|
|
case 'svgz':
|
|
this._setSkinSVG(skin_md5ext);
|
|
break;
|
|
default:
|
|
this._setSkinBitmap(skin_md5ext);
|
|
break;
|
|
}
|
|
}
|
|
else {
|
|
this._useSkin(null, 0, 0, 1, true);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Use a skin if it is the currently-pending skin, or if skipPendingCheck==true.
|
|
* If the passed skin is used (for either reason) _pendingSkin will be cleared.
|
|
* @param {WebGLTexture} skin The skin to use.
|
|
* @param {int} width The width of the skin.
|
|
* @param {int} height The height of the skin.
|
|
* @param {int} costumeResolution The resolution to use for this skin.
|
|
* @param {boolean} [skipPendingCheck] If true, don't compare to _pendingSkin.
|
|
* @private
|
|
*/
|
|
Drawable.prototype._useSkin = function(
|
|
skin, width, height, costumeResolution, skipPendingCheck) {
|
|
|
|
if (skipPendingCheck || (skin == this._pendingSkin)) {
|
|
this._pendingSkin = null;
|
|
if (this._uniforms.u_skin && (this._uniforms.u_skin != skin)) {
|
|
this._gl.deleteTexture(this._uniforms.u_skin);
|
|
}
|
|
this._setSkinSize(width, height, costumeResolution);
|
|
this._uniforms.u_skin = skin;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Fetch the shader for this Drawable's set of active effects.
|
|
* Build the shader if necessary.
|
|
* @param {Drawable.DRAW_MODE} drawMode Draw normally, silhouette, etc.
|
|
* @returns {module:twgl.ProgramInfo?} The shader's program info.
|
|
*/
|
|
Drawable.prototype.getShader = function (drawMode) {
|
|
var cache = Drawable._shaderCache[drawMode];
|
|
if (!cache) {
|
|
cache = Drawable._shaderCache[drawMode] = [];
|
|
}
|
|
var shader = cache[this._shaderIndex];
|
|
if (!shader) {
|
|
shader = cache[this._shaderIndex] = this._buildShader(drawMode);
|
|
}
|
|
return shader;
|
|
};
|
|
|
|
/**
|
|
* Build the shader for this Drawable's set of active effects.
|
|
* @param {Drawable.DRAW_MODE} drawMode Draw normally, silhouette, etc.
|
|
* @returns {module:twgl.ProgramInfo?} The new shader's program info.
|
|
* @private
|
|
*/
|
|
Drawable.prototype._buildShader = function (drawMode) {
|
|
var numEffects = Drawable.EFFECTS.length;
|
|
|
|
var defines = [
|
|
'#define DRAW_MODE_' + drawMode
|
|
];
|
|
for (var index = 0; index < numEffects; ++index) {
|
|
if ((this._shaderIndex & (1 << index)) != 0) {
|
|
defines.push('#define ENABLE_' + Drawable.EFFECTS[index]);
|
|
}
|
|
}
|
|
|
|
var definesText = defines.join('\n') + '\n';
|
|
var vsFullText = definesText + require('./shaders/sprite.vert');
|
|
var fsFullText = definesText + require('./shaders/sprite.frag');
|
|
|
|
return twgl.createProgramInfo(this._gl, [vsFullText, fsFullText]);
|
|
};
|
|
|
|
/**
|
|
* Load a bitmap skin. Supports the same formats as the Image element.
|
|
* @param {string} skin_md5ext The MD5 and file extension of the bitmap skin.
|
|
* @private
|
|
*/
|
|
Drawable.prototype._setSkinBitmap = function (skin_md5ext) {
|
|
var url =
|
|
'https://cdn.assets.scratch.mit.edu/internalapi/asset/' +
|
|
skin_md5ext +
|
|
'/get/';
|
|
this._setSkinCore(url, 2);
|
|
};
|
|
|
|
/**
|
|
* Load an SVG-based skin. This still needs quite a bit of work to match the
|
|
* level of quality found in Scratch 2.0:
|
|
* - We should detect when a skin is being scaled up and render the SVG at a
|
|
* higher resolution in those cases.
|
|
* - Colors seem a little off. This may be browser-specific.
|
|
* - This method works in Chrome, Firefox, Safari, and Edge but causes a
|
|
* security error in IE.
|
|
* @param {string} skin_md5ext The MD5 and file extension of the SVG skin.
|
|
* @private
|
|
*/
|
|
Drawable.prototype._setSkinSVG = function (skin_md5ext) {
|
|
var url =
|
|
'https://cdn.assets.scratch.mit.edu/internalapi/asset/' +
|
|
skin_md5ext +
|
|
'/get/';
|
|
var instance = this;
|
|
function gotSVG(err, response, body) {
|
|
if (!err) {
|
|
svgToImage(body, gotImage);
|
|
}
|
|
}
|
|
function gotImage(err, image) {
|
|
if (!err) {
|
|
instance._setSkinCore(image, 1);
|
|
}
|
|
}
|
|
xhr.get({
|
|
useXDR: true,
|
|
url: url
|
|
}, gotSVG);
|
|
// TODO: if there's no current u_skin, install *something* before returning
|
|
};
|
|
|
|
/**
|
|
* Common code for setting all skin types.
|
|
* @param {string|Image} source The source of image data for the skin.
|
|
* @param {int} costumeResolution The resolution to use for this skin.
|
|
* @private
|
|
*/
|
|
Drawable.prototype._setSkinCore = function (source, costumeResolution) {
|
|
var instance = this;
|
|
var callback = function (err, texture, source) {
|
|
if (!err && (instance._pendingSkin == texture)) {
|
|
instance._useSkin(
|
|
texture, source.width, source.height, costumeResolution);
|
|
}
|
|
};
|
|
|
|
var gl = this._gl;
|
|
var options = {
|
|
auto: true,
|
|
mag: gl.NEAREST,
|
|
min: gl.NEAREST, // TODO: mipmaps, linear (except pixelate)
|
|
wrap: gl.CLAMP_TO_EDGE,
|
|
src: source
|
|
};
|
|
var willCallCallback = typeof source == 'string';
|
|
instance._pendingSkin = twgl.createTexture(
|
|
gl, options, willCallCallback ? callback : null);
|
|
|
|
// If we don't already have a texture, or if we won't get a callback when
|
|
// the new one loads, then just start using the texture immediately.
|
|
if (willCallCallback) {
|
|
if (!this._uniforms.u_skin) {
|
|
this._uniforms.u_skin = instance._pendingSkin;
|
|
this._setSkinSize(0, 0);
|
|
}
|
|
}
|
|
else {
|
|
callback(null, instance._pendingSkin, source);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Retrieve the shader uniforms to be used when rendering this Drawable.
|
|
* @returns {Object.<string, *>}
|
|
*/
|
|
Drawable.prototype.getUniforms = function () {
|
|
if (this._transformDirty) {
|
|
this._calculateTransform();
|
|
}
|
|
return this._uniforms;
|
|
};
|
|
|
|
/**
|
|
* Update the position, direction, scale, or effect properties of this Drawable.
|
|
* @param {Object.<string,*>} properties The new property values to set.
|
|
*/
|
|
Drawable.prototype.updateProperties = function (properties) {
|
|
var dirty = false;
|
|
if ('skin' in properties) {
|
|
this.setSkin(properties.skin);
|
|
}
|
|
if ('position' in properties && (
|
|
this._position[0] != properties.position[0] ||
|
|
this._position[1] != properties.position[1])) {
|
|
this._position[0] = properties.position[0];
|
|
this._position[1] = properties.position[1];
|
|
dirty = true;
|
|
}
|
|
if ('direction' in properties && this._direction != properties.direction) {
|
|
this._direction = properties.direction;
|
|
dirty = true;
|
|
}
|
|
if ('scale' in properties && this._scale != properties.scale) {
|
|
this._scale = properties.scale;
|
|
dirty = true;
|
|
}
|
|
if (dirty) {
|
|
this.setTransformDirty();
|
|
}
|
|
var numEffects = Drawable.EFFECTS.length;
|
|
for (var index = 0; index < numEffects; ++index) {
|
|
var propertyName = Drawable.EFFECTS[index];
|
|
if (propertyName in properties) {
|
|
var rawValue = properties[propertyName];
|
|
var mask = 1 << index;
|
|
if (rawValue != 0) {
|
|
this._shaderIndex |= mask;
|
|
}
|
|
else {
|
|
this._shaderIndex &= ~mask;
|
|
}
|
|
var converter = Drawable._effectConverter[propertyName];
|
|
this._uniforms['u_' + propertyName] = converter(rawValue);
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Set the dimensions of this Drawable's skin.
|
|
* @param {int} width The width of the new skin.
|
|
* @param {int} height The height of the new skin.
|
|
* @param {int} costumeResolution The resolution to use for this skin.
|
|
* @private
|
|
*/
|
|
Drawable.prototype._setSkinSize = function (width, height, costumeResolution) {
|
|
costumeResolution = costumeResolution || 1;
|
|
width /= costumeResolution;
|
|
height /= costumeResolution;
|
|
if (this._uniforms.u_skinSize[0] != width
|
|
|| this._uniforms.u_skinSize[1] != height) {
|
|
this._uniforms.u_skinSize[0] = width;
|
|
this._uniforms.u_skinSize[1] = height;
|
|
this.setTransformDirty();
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Calculate the transform to use when rendering this Drawable.
|
|
* @private
|
|
*/
|
|
Drawable.prototype._calculateTransform = function () {
|
|
var modelMatrix = this._uniforms.u_modelMatrix;
|
|
|
|
twgl.m4.identity(modelMatrix);
|
|
twgl.m4.translate(modelMatrix, this._position, modelMatrix);
|
|
|
|
var rotation = (270 - this._direction) * Math.PI / 180;
|
|
twgl.m4.rotateZ(modelMatrix, rotation, modelMatrix);
|
|
|
|
var scaledSize = twgl.v3.mulScalar(
|
|
this._uniforms.u_skinSize, this._scale / 100);
|
|
scaledSize[2] = 0; // was NaN because u_skinSize has only 2 components
|
|
twgl.m4.scale(modelMatrix, scaledSize, modelMatrix);
|
|
|
|
this._transformDirty = false;
|
|
};
|
|
|
|
/**
|
|
* 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 Drawable.NONE.
|
|
* @param {int} id The ID to convert.
|
|
* @returns {number[]} An array of [r,g,b,a], each component in the range [0,1].
|
|
*/
|
|
Drawable.color4fFromID = function(id) {
|
|
id -= Drawable.NONE;
|
|
var r = ((id >> 0) & 255) / 255.0;
|
|
var g = ((id >> 8) & 255) / 255.0;
|
|
var 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 Drawable.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].
|
|
* @param {int} a The alpha value of the color, in the range [0,255].
|
|
* @returns {int} The ID represented by that color.
|
|
*/
|
|
// eslint-disable-next-line no-unused-vars
|
|
Drawable.color4ubToID = function(r, g, b, a) {
|
|
var id;
|
|
id = (r & 255) << 0;
|
|
id |= (g & 255) << 8;
|
|
id |= (b & 255) << 16;
|
|
return id + Drawable.NONE;
|
|
};
|