Framebuffer PenSkin (#319)

* draw pen skin lines and stamps to a framebuffer

* skip reading pixels and draw stamp to framebuffer

* update silhouette with readPixels

* draw pen canvas to buffer when its dirty

Composite lines and stamps on browser preferred side (cpu/gpu) until
the export texture is needed. Then blend the canvas with the current
buffer contents.

Updating this way invalidates useProgram optimization and the renderer
currently does not have a way to know this.

* draw lines on framebuffer through fragment shader

* optimize draw regions and pen skin matrix creation

* control draw regions

* mobile gpus need high precision floats for line drawing

* optimize cpu pen line math

* sampled pen line caps

* sampleless pen skin lines, lint, document pen skin buffer ops

* add PenSkin._canvasDirty to constructor

* remove DRAW_MODE_line

* comment PenSkin reused memory, use memory in drawRectangle

* turn draw region id's into optional method handlers

A region ID object may have an enter and exit method on it that are
used by default when entering and exiting that region.

* remove old DRAW_MODE_line precision setting

* standardize vert lines on 4 spaces

* fixup! turn draw region id's into optional method handlers

* do not draw when updating pen skin silhouette

* do not premultiply stamp colors by alpha

* fixup! do not draw when updating pen skin silhouette

* do not premultiply line color

* add a small rim around the line for aliasing

* variable pen line alias amount

* reverse offset pen line on y axis by relative alias amount

Reverse the offset to keep small line overlap to a minimum.

* fixup! reverse offset pen line on y axis by relative alias amount

* medium precision gpu floats
This commit is contained in:
Michael "Z" Goddard
2018-08-08 14:30:51 -04:00
committed by GitHub
parent c54a928f0a
commit 4c8bc5d806
5 changed files with 563 additions and 42 deletions

View File

@@ -3,6 +3,9 @@ const twgl = require('twgl.js');
const RenderConstants = require('./RenderConstants');
const Skin = require('./Skin');
const Rectangle = require('./Rectangle');
const ShaderManager = require('./ShaderManager');
/**
* Attributes to use when drawing with the pen
* @typedef {object} PenSkin#PenAttributes
@@ -23,6 +26,48 @@ const DefaultPenAttributes = {
};
/**
* Reused memory location for projection matrices.
* @type {FloatArray}
*/
const __projectionMatrix = twgl.m4.identity();
/**
* Reused memory location for translation matrix for building a model matrix.
* @type {FloatArray}
*/
const __modelTranslationMatrix = twgl.m4.identity();
/**
* Reused memory location for rotation matrix for building a model matrix.
* @type {FloatArray}
*/
const __modelRotationMatrix = twgl.m4.identity();
/**
* Reused memory location for scaling matrix for building a model matrix.
* @type {FloatArray}
*/
const __modelScalingMatrix = twgl.m4.identity();
/**
* Reused memory location for a model matrix.
* @type {FloatArray}
*/
const __modelMatrix = twgl.m4.identity();
/**
* Reused memory location for a vector to create a translation matrix from.
* @type {FloatArray}
*/
const __modelTranslationVector = twgl.v3.create();
/**
* Reused memory location for a vector to create a scaling matrix from.
* @type {FloatArray}
*/
const __modelScalingVector = twgl.v3.create();
class PenSkin extends Skin {
/**
* Create a Skin which implements a Scratch pen layer.
@@ -43,15 +88,48 @@ class PenSkin extends Skin {
/** @type {HTMLCanvasElement} */
this._canvas = document.createElement('canvas');
/** @type {boolean} */
this._canvasDirty = false;
/** @type {WebGLTexture} */
this._texture = null;
/** @type {WebGLTexture} */
this._exportTexture = null;
/** @type {WebGLFramebuffer} */
this._framebuffer = null;
/** @type {WebGLFramebuffer} */
this._silhouetteBuffer = null;
/** @type {boolean} */
this._canvasDirty = false;
/** @type {boolean} */
this._silhouetteDirty = false;
/** @type {object} */
this._lineOnBufferDrawRegionId = {
enter: () => this._enterDrawLineOnBuffer(),
exit: () => this._exitDrawLineOnBuffer()
};
/** @type {object} */
this._toBufferDrawRegionId = {
enter: () => this._enterDrawToBuffer(),
exit: () => this._exitDrawToBuffer()
};
/** @type {twgl.BufferInfo} */
this._lineBufferInfo = null;
const NO_EFFECTS = 0;
/** @type {twgl.ProgramInfo} */
this._stampShader = this._renderer._shaderManager.getShader(ShaderManager.DRAW_MODE.stamp, NO_EFFECTS);
/** @type {twgl.ProgramInfo} */
this._lineShader = this._renderer._shaderManager.getShader(ShaderManager.DRAW_MODE.lineSample, NO_EFFECTS);
this._createLineGeometry();
this.onNativeSizeChanged = this.onNativeSizeChanged.bind(this);
this._renderer.on(RenderConstants.Events.NativeSizeChanged, this.onNativeSizeChanged);
@@ -64,6 +142,7 @@ class PenSkin extends Skin {
dispose () {
this._renderer.removeListener(RenderConstants.Events.NativeSizeChanged, this.onNativeSizeChanged);
this._renderer.gl.deleteTexture(this._texture);
this._renderer.gl.deleteTexture(this._exportTexture);
this._texture = null;
super.dispose();
}
@@ -90,23 +169,25 @@ class PenSkin extends Skin {
// eslint-disable-next-line no-unused-vars
getTexture (pixelsWide, pixelsTall) {
if (this._canvasDirty) {
this._canvasDirty = false;
const gl = this._renderer.gl;
gl.bindTexture(gl.TEXTURE_2D, this._texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, this._canvas);
this._drawToBuffer();
}
return this._texture;
return this._exportTexture;
}
/**
* Clear the pen layer.
*/
clear () {
const gl = this._renderer.gl;
twgl.bindFramebufferInfo(gl, this._framebuffer);
gl.clearColor(1, 1, 1, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
const ctx = this._canvas.getContext('2d');
ctx.clearRect(0, 0, this._canvas.width, this._canvas.height);
this._canvasDirty = true;
this._silhouetteDirty = true;
}
@@ -130,17 +211,196 @@ class PenSkin extends Skin {
* @param {number} y1 - the Y coordinate of the end of the line.
*/
drawLine (penAttributes, x0, y0, x1, y1) {
const ctx = this._canvas.getContext('2d');
this._setAttributes(ctx, penAttributes);
// Width 1 and 3 lines need to be offset by 0.5.
const diameter = penAttributes.diameter || DefaultPenAttributes.diameter;
const offset = (Math.max(4 - diameter, 0) % 2) / 2;
ctx.beginPath();
ctx.moveTo(this._rotationCenter[0] + x0 + offset, this._rotationCenter[1] - y0 + offset);
ctx.lineTo(this._rotationCenter[0] + x1 + offset, this._rotationCenter[1] - y1 + offset);
ctx.stroke();
this._canvasDirty = true;
this._drawLineOnBuffer(
penAttributes,
this._rotationCenter[0] + x0 + offset, this._rotationCenter[1] - y0 + offset,
this._rotationCenter[0] + x1 + offset, this._rotationCenter[1] - y1 + offset
);
this._silhouetteDirty = true;
}
/**
* Create 2D geometry for drawing lines to a framebuffer.
*/
_createLineGeometry () {
// Create a set of triangulated quads that break up a line into 3 parts:
// 2 caps and a body. The y component of these position vertices are
// divided to bring a value of 1 down to 0.5 to 0. The large y values
// are set so they will still be at least 0.5 after division. The
// divisor is scaled based on the length of the line and the lines
// width.
//
// Texture coordinates are based on a "generated" texture whose general
// shape is a circle. The line caps set their texture values to define
// there roundedness with the texture. The body has all of its texture
// valeus set to the center of the texture so its a solid block.
const quads = {
a_position: {
numComponents: 2,
data: [
-0.5, 1,
0.5, 1,
-0.5, 100000,
-0.5, 100000,
0.5, 1,
0.5, 100000,
-0.5, 1,
0.5, 1,
-0.5, -1,
-0.5, -1,
0.5, 1,
0.5, -1,
-0.5, -100000,
0.5, -100000,
-0.5, -1,
-0.5, -1,
0.5, -100000,
0.5, -1
]
},
a_texCoord: {
numComponents: 2,
data: [
1, 0.5,
0, 0.5,
1, 0,
1, 0,
0, 0.5,
0, 0,
0.5, 0,
0.5, 1,
0.5, 0,
0.5, 0,
0.5, 1,
0.5, 1,
1, 0,
0, 0,
1, 0.5,
1, 0.5,
0, 0,
0, 0.5
]
}
};
this._lineBufferInfo = twgl.createBufferInfoFromArrays(this._renderer.gl, quads);
}
/**
* Prepare to draw lines in the _lineOnBufferDrawRegionId region.
*/
_enterDrawLineOnBuffer () {
const gl = this._renderer.gl;
const bounds = this._bounds;
const currentShader = this._lineShader;
const projection = twgl.m4.ortho(0, bounds.width, 0, bounds.height, -1, 1, __projectionMatrix);
twgl.bindFramebufferInfo(gl, this._framebuffer);
// Needs a blend function that blends a destination that starts with
// no alpha.
gl.blendFuncSeparate(
gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA,
gl.ONE, gl.ONE_MINUS_SRC_ALPHA
);
gl.viewport(0, 0, bounds.width, bounds.height);
gl.useProgram(currentShader.program);
twgl.setBuffersAndAttributes(gl, currentShader, this._lineBufferInfo);
const uniforms = {
u_skin: this._texture,
u_projectionMatrix: projection,
u_fudge: 0
};
twgl.setUniforms(currentShader, uniforms);
}
/**
* Return to a base state from _lineOnBufferDrawRegionId.
*/
_exitDrawLineOnBuffer () {
const gl = this._renderer.gl;
gl.blendFuncSeparate(gl.ONE, gl.ONE_MINUS_SRC_ALPHA, gl.ZERO, gl.ONE);
twgl.bindFramebufferInfo(gl, null);
}
/**
* Draw a line on the framebuffer.
* @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) {
const gl = this._renderer.gl;
const currentShader = this._lineShader;
this._renderer.enterDrawRegion(this._lineOnBufferDrawRegionId);
const diameter = penAttributes.diameter || DefaultPenAttributes.diameter;
const length = Math.hypot(Math.abs(x1 - x0) - 0.001, Math.abs(y1 - y0) - 0.001);
const avgX = (x0 + x1) / 2;
const avgY = (y0 + y1) / 2;
const theta = Math.atan2(y0 - y1, x0 - x1);
const alias = x0 === x1 || y0 === y1 ? 0 : 1;
// The line needs a bit of aliasing to look smooth. Add a small offset
// and a small size boost to scaling to give a section to alias.
const translationVector = __modelTranslationVector;
translationVector[0] = avgX - (alias / 2);
translationVector[1] = avgY + (alias / 4);
const scalingVector = __modelScalingVector;
scalingVector[0] = diameter + alias;
scalingVector[1] = length + diameter - (alias / 2);
const radius = diameter / 2;
const yScalar = (0.50001 - (radius / (length + diameter)));
const uniforms = {
u_positionScalar: yScalar,
u_capScale: diameter,
u_aliasAmount: alias,
u_modelMatrix: twgl.m4.multiply(
twgl.m4.multiply(
twgl.m4.translation(translationVector, __modelTranslationMatrix),
twgl.m4.rotationZ(theta - (Math.PI / 2), __modelRotationMatrix),
__modelMatrix
),
twgl.m4.scaling(scalingVector, __modelScalingMatrix),
__modelMatrix
),
u_lineColor: penAttributes.color4f || DefaultPenAttributes.color4f
};
twgl.setUniforms(currentShader, uniforms);
twgl.drawBufferInfo(gl, this._lineBufferInfo, gl.TRIANGLES);
this._silhouetteDirty = true;
}
@@ -152,11 +412,133 @@ class PenSkin extends Skin {
*/
drawStamp (stampElement, x, y) {
const ctx = this._canvas.getContext('2d');
ctx.drawImage(stampElement, this._rotationCenter[0] + x, this._rotationCenter[1] - y);
this._canvasDirty = true;
this._silhouetteDirty = true;
}
/**
* Enter a draw region to draw a rectangle.
*
* Multiple calls with the same regionId skip the callback reducing the
* amount of GL state changes.
* @param {twgl.ProgramInfo} currentShader - program info to draw rectangle
* with
* @param {Rectangle} bounds - viewport bounds to draw in
* region
*/
_drawRectangleRegionEnter (currentShader, bounds) {
const gl = this._renderer.gl;
gl.viewport(0, 0, bounds.width, bounds.height);
gl.useProgram(currentShader.program);
twgl.setBuffersAndAttributes(gl, currentShader, this._renderer._bufferInfo);
}
/**
* Draw a rectangle.
* @param {twgl.ProgramInfo} currentShader - program info to draw rectangle
* with
* @param {WebGLTexture} texture - texture to draw
* @param {Rectangle} bounds - bounded area to draw in
* @param {number} x - centered at x
* @param {number} y - centered at y
*/
_drawRectangle (currentShader, texture, bounds, x = -this._canvas.width / 2, y = this._canvas.height / 2) {
const gl = this._renderer.gl;
const projection = twgl.m4.ortho(
bounds.left, bounds.right, bounds.top, bounds.bottom, -1, 1,
__projectionMatrix
);
const uniforms = {
u_skin: texture,
u_projectionMatrix: projection,
u_modelMatrix: twgl.m4.multiply(
twgl.m4.translation(twgl.v3.create(
-x - (bounds.width / 2),
-y + (bounds.height / 2),
0
), __modelTranslationMatrix),
twgl.m4.scaling(twgl.v3.create(
bounds.width,
bounds.height,
0
), __modelScalingMatrix),
__modelMatrix
),
u_fudge: 0
};
twgl.setTextureParameters(gl, texture, {minMag: gl.NEAREST});
twgl.setUniforms(currentShader, uniforms);
twgl.drawBufferInfo(gl, this._renderer._bufferInfo, gl.TRIANGLES);
}
/**
* Prepare to draw a rectangle in the _toBufferDrawRegionId region.
*/
_enterDrawToBuffer () {
const gl = this._renderer.gl;
twgl.bindFramebufferInfo(gl, this._framebuffer);
gl.blendFuncSeparate(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA, gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
this._drawRectangleRegionEnter(this._stampShader, this._bounds);
}
/**
* Return to a base state from _toBufferDrawRegionId.
*/
_exitDrawToBuffer () {
const gl = this._renderer.gl;
gl.blendFuncSeparate(gl.ONE, gl.ONE_MINUS_SRC_ALPHA, gl.ZERO, gl.ONE);
twgl.bindFramebufferInfo(gl, null);
}
/**
* Draw the input texture to the framebuffer.
* @param {WebGLTexture} texture - input texture to draw
* @param {number} x - texture centered at x
* @param {number} y - texture centered at y
*/
_drawToBuffer (texture = this._texture, x = -this._canvas.width / 2, y = this._canvas.height / 2) {
if (texture !== this._texture && this._canvasDirty) {
this._drawToBuffer();
}
const gl = this._renderer.gl;
// If the input texture is the one that represents the pen's canvas
// layer, update the texture with the canvas data.
if (texture === this._texture) {
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, this._canvas);
const ctx = this._canvas.getContext('2d');
ctx.clearRect(0, 0, this._canvas.width, this._canvas.height);
this._canvasDirty = false;
}
const currentShader = this._stampShader;
const bounds = this._bounds;
this._renderer.enterDrawRegion(this._toBufferDrawRegionId);
this._drawRectangle(currentShader, texture, bounds, x, y);
this._silhouetteDirty = true;
}
/**
* React to a change in the renderer's native size.
* @param {object} event - The change event.
@@ -174,10 +556,15 @@ class PenSkin extends Skin {
const [width, height] = canvasSize;
const gl = this._renderer.gl;
this._bounds = new Rectangle();
this._bounds.initFromBounds(width / 2, width / -2, height / 2, height / -2);
this._canvas.width = width;
this._canvas.height = height;
this._rotationCenter[0] = width / 2;
this._rotationCenter[1] = height / 2;
this._texture = twgl.createTexture(
gl,
{
@@ -188,7 +575,36 @@ class PenSkin extends Skin {
src: this._canvas
}
);
this._canvasDirty = true;
this._exportTexture = twgl.createTexture(
gl,
{
auto: true,
mag: gl.NEAREST,
min: gl.NEAREST,
wrap: gl.CLAMP_TO_EDGE,
width,
height
}
);
const attachments = [
{
format: gl.RGBA,
attachment: this._exportTexture
}
];
if (this._framebuffer) {
twgl.resizeFramebufferInfo(gl, this._framebuffer, attachments, width, height);
twgl.resizeFramebufferInfo(gl, this._silhouetteBuffer, [{format: gl.RGBA}], width, height);
} else {
this._framebuffer = twgl.createFramebufferInfo(gl, attachments, width, height);
this._silhouetteBuffer = twgl.createFramebufferInfo(gl, [{format: gl.RGBA}], width, height);
}
gl.clearColor(1, 1, 1, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
this._silhouetteDirty = true;
}
@@ -220,9 +636,32 @@ class PenSkin extends Skin {
updateSilhouette () {
if (this._silhouetteDirty) {
if (this._canvasDirty) {
this.getTexture();
this._drawToBuffer();
}
// Render export texture to another framebuffer
const gl = this._renderer.gl;
const bounds = this._bounds;
this._renderer.enterDrawRegion(this._toBufferDrawRegionId);
// Sample the framebuffer's pixels into the silhouette instance
const skinPixels = new Uint8Array(Math.floor(this._canvas.width * this._canvas.height * 4));
gl.readPixels(0, 0, this._canvas.width, this._canvas.height, gl.RGBA, gl.UNSIGNED_BYTE, skinPixels);
const skinCanvas = this._canvas;
skinCanvas.width = bounds.width;
skinCanvas.height = bounds.height;
const skinContext = skinCanvas.getContext('2d');
const skinImageData = skinContext.createImageData(bounds.width, bounds.height);
skinImageData.data.set(skinPixels);
skinContext.putImageData(skinImageData, 0, 0);
this._silhouette.update(this._canvas);
this._silhouetteDirty = false;
}
}
}