diff --git a/libs/drawing-engine/src/programs/BrushDrawingProgram.ts b/libs/drawing-engine/src/programs/BrushDrawingProgram.ts new file mode 100644 index 0000000..fe9fcc9 --- /dev/null +++ b/libs/drawing-engine/src/programs/BrushDrawingProgram.ts @@ -0,0 +1,112 @@ +import { BrushProgramBase } from "./base/BrushProgramBase" +import { DrawType } from "./base/PositionColorProgramBase" +import { Color } from "@libs/shared" + +export { DrawType } from "./base/PositionColorProgramBase" + +export interface LineInfo { + points: number[] + pressure?: number[] +} + +export class BrushDrawingProgram extends BrushProgramBase { + constructor(gl: WebGLRenderingContext, pixelDensity: number) { + super(gl, pixelDensity) + } + + public syncCanvasSize(): typeof this { + super.syncCanvasSize() + this.gl.uniform2f(this.getUniformLocation("canvasSize"), this.gl.canvas.width, this.gl.canvas.height) + return this + } + + public draw({ points }: Readonly, { drawType = this.gl.STREAM_DRAW, ...options }: BrushDrawOptions = {}) { + this.useProgram() + this.syncCanvasSize() + + this.setBrushOptions(options) + + // if (pressure && pressure.length !== points.length / 2) { + // console.warn("Pressure array should be the same length as the points array", { + // pressureValues: pressure.length, + // points: points.length / 2, + // }) + // } + + // const hasPressureData = (pressure?.filter((p) => p > 0).length ?? 0) > 1 + + // if (hasPressureData) { + // this.bufferAttribute("pressure", new Float32Array(pressure!), { usage: drawType, size: 1 }) + // } + + const canvasBounds = [-1, -1, 1, -1, -1, 1, 1, 1] + + // repeat the bounds until it's the same length as the points array + const bounds = new Array(points.length).fill(0).map((_, i) => canvasBounds[i % canvasBounds.length]) + + this.bufferAttribute("bounds", new Float32Array(bounds), { usage: drawType, size: 2 }) + this.bufferAttribute("position", new Float32Array(points), { usage: drawType, size: 2 }) + this.gl.drawArrays(this.gl.TRIANGLES, 0, points.length / 2) + this.checkError() + } + + private setBrushOptions({ + color = Color.BLACK, + diameter = 5.0, + minDiameter = 1.0, + opacity = 255, + hardness = 1.0, + flow = 1.0, + }: Omit) { + this.gl.uniform1f(this.getUniformLocation("opacity"), opacity) + this.gl.uniform1f(this.getUniformLocation("strokeRadiusMax"), diameter / 2) + this.gl.uniform1f(this.getUniformLocation("strokeRadiusMin"), minDiameter / 2) + this.gl.uniform2fv(this.getUniformLocation("hardness"), [hardness, hardness]) + this.gl.uniform2fv(this.getUniformLocation("flow"), [flow, flow]) + // not sure what this should be yet + this.gl.uniform1f(this.getUniformLocation("strokeLength"), 1.0) + this.gl.uniform3fv(this.getUniformLocation("color"), color.vec3) + + const isPremultipliedAlpha = this.gl.getParameter(this.gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL) + + this.gl.uniform1i(this.getUniformLocation("isPremultipliedAlpha"), isPremultipliedAlpha ? 1 : 0) + + // uniform float uOpacity; + // uniform float uStrokeRadiusMax; + // uniform float uStrokeRadiusMin; + // uniform vec2 uHardness; + // uniform vec2 uFlow; + // uniform vec3 uColor; + // uniform float uStrokeLength; + // uniform bool uIsPremultipliedAlpha; + } +} + +export interface BrushDrawOptions { + /** + * The draw type to use when drawing the line. Defaults to `gl.STREAM_DRAW`. + */ + drawType?: DrawType + /* + * The color to use when drawing the line. Black if not specified. + */ + color?: Color + + /** + * The diameter of the line. Defaults to 5.0. + */ + diameter?: number + + /** + * The minimum diameter of the line. Defaults to 1.0. + */ + minDiameter?: number + + /** + * The opacity of the line. Defaults to 255.0. + */ + opacity?: number + + hardness?: number + flow?: number +} diff --git a/libs/drawing-engine/src/programs/base/BrushProgramBase.ts b/libs/drawing-engine/src/programs/base/BrushProgramBase.ts new file mode 100644 index 0000000..021c2a4 --- /dev/null +++ b/libs/drawing-engine/src/programs/base/BrushProgramBase.ts @@ -0,0 +1,30 @@ +import { WebGLProgramBuilder } from "@libs/shared" +import { BaseProgram } from "@libs/shared" +import sourceMap, { uniformNames, attributeNames } from "../shaders/sourceMap" + +const VERTEX_SHADER = "brush.vertex" +const FRAGMENT_SHADER = "brush.fragment" +const UNIFORM_NAMES = { ...uniformNames[FRAGMENT_SHADER], ...uniformNames[VERTEX_SHADER] } as const +const ATTRIBUTE_NAMES = { ...attributeNames[VERTEX_SHADER] } as const + +export abstract class BrushProgramBase extends BaseProgram { + constructor(gl: WebGLRenderingContext, pixelDensity: number) { + super(BrushProgramBase.createContextStatic(gl, BrushProgramBase.createProgramStatic(gl)), pixelDensity) + } + + protected static createProgramStatic(gl: WebGLRenderingContext): WebGLProgram { + return WebGLProgramBuilder.createFromSourceMap(gl, sourceMap, VERTEX_SHADER, FRAGMENT_SHADER) + } + + protected createProgram(gl: WebGLRenderingContext): WebGLProgram { + return BrushProgramBase.createProgramStatic(gl) + } + + protected static createContextStatic(context: WebGLRenderingContext, program: WebGLProgram) { + return BaseProgram.getProgramInfo(context, program, UNIFORM_NAMES, ATTRIBUTE_NAMES) + } + + protected createProgramInfo(context: WebGLRenderingContext, program: WebGLProgram) { + return BrushProgramBase.createContextStatic(context, program) + } +} diff --git a/libs/drawing-engine/src/programs/shaders/brush.fragment.glsl b/libs/drawing-engine/src/programs/shaders/brush.fragment.glsl new file mode 100644 index 0000000..d0dffe0 --- /dev/null +++ b/libs/drawing-engine/src/programs/shaders/brush.fragment.glsl @@ -0,0 +1,29 @@ +precision mediump float; + +#define M_PI 3.1415926535897932384626433832795 + +uniform vec3 uColor; +uniform int uIsPremultipliedAlpha; + +varying vec2 vPosition; + +#require "softBrush"; + +void main() { + float aspect = (uStrokeLength / (2.0 * uStrokeRadiusMax)) + 1.0; + + float U = gl_FragCoord.x - vPosition.x; + float V = gl_FragCoord.y - vPosition.y; + float x = U * aspect; + float y = V; + + float a = alpha(x, y, aspect); + + vec4 color = vec4(uColor / vec3(255.0), a); + + if(uIsPremultipliedAlpha == 1) { + color.rgb *= color.a; + } + + gl_FragColor = color; +} diff --git a/libs/drawing-engine/src/programs/shaders/brush.vertex.glsl b/libs/drawing-engine/src/programs/shaders/brush.vertex.glsl new file mode 100644 index 0000000..fc54275 --- /dev/null +++ b/libs/drawing-engine/src/programs/shaders/brush.vertex.glsl @@ -0,0 +1,12 @@ +attribute vec2 aPosition; +attribute vec2 aBounds; + +#require "normalizeCoords"; + +varying vec2 vPosition; + +void main() { + vPosition = normalizeCoords(aPosition); + + gl_Position = vec4(aBounds, 0.0, 1.0); +} diff --git a/libs/drawing-engine/src/programs/shaders/inc/softBrush.glsl b/libs/drawing-engine/src/programs/shaders/inc/softBrush.glsl new file mode 100644 index 0000000..81d650e --- /dev/null +++ b/libs/drawing-engine/src/programs/shaders/inc/softBrush.glsl @@ -0,0 +1,89 @@ +// Credit: https://jcgt.org/published/0007/01/01/paper.pdf + +uniform float uOpacity; +uniform float uStrokeRadiusMax; +uniform float uStrokeRadiusMin; +uniform vec2 uHardness; +uniform vec2 uFlow; +uniform float uStrokeLength; + +float psi(float x) { + return (uStrokeRadiusMax * (2.0 * x - 1.0)) / uStrokeLength; +} + +float flow(float x) { + // mix = lerp + return mix(uFlow[0], uFlow[1], psi(x)); +} + +float hardness(float x) { + return mix(uHardness[0], uHardness[1], psi(x)); +} + +float radius(float x) { + return mix(0.5, uStrokeRadiusMin / (2.0 * uStrokeRadiusMax), psi(x)); +} + +float dist(float x, float X, float Y) { + return sqrt(pow(X - x, 2.0) + pow(Y - 0.5, 2.0)); +} + +float theta(float x, float X, float Y) { + return dist(x, X, Y) / radius(x); +} + +float phase(float x) { + return M_PI / 2.0 * (1.0 - (1.0 / (1.0 - hardness(x)))); +} + +float falloff(float x, float X, float Y) { + if(theta(x, X, Y) < hardness(x)) { + return 1.0; + } else { + float piTheta = M_PI * theta(x, X, Y); + float iHardness = 1.0 - hardness(x); + return pow(cos(piTheta / (2.0 * iHardness) + phase(x)), 2.0); + } +} + +float alpha(float X, float Y, float aspect) { + float BE = abs(Y - 0.5); + float BD = radius(X); + float BO = (uStrokeLength * BD) / (uStrokeRadiusMax - uStrokeRadiusMin); + float lambda = pow(BD, 2.0) - ((pow(BD, 2.0) * pow(BE, 2.0)) / pow(BO, 2.0)); + float epsilon = pow(4.0 * pow(BD, 4.0), 4.0) / pow(BO, 2.0); + float r1 = 0.0; + float r2 = 0.0; + + if(uStrokeRadiusMin == uStrokeRadiusMax) { + r1 = 0.25; + r2 = 0.25; + } else { + // breaks down the quadratic formula into parts so I can look at it without going crazy + float a = (2.0 * lambda) + epsilon; + + // I pulled the negative out of b and c to make them easier to read + // it's reintroduced in d + float b = pow(lambda, 2.0) + (epsilon * pow(BE, 2.0)); + float c = a + (4.0 * b); + float d = sqrt(-4.0 * c); + + r1 = (a + d) / 2.0; + r2 = (a - d) / 2.0; + } + + float X1 = clamp(X - sqrt(r1 - pow(BE, 2.0)), 0.5, aspect - 0.5); + float X2 = clamp(X + sqrt(r2 - pow(BE, 2.0)), 0.5, aspect - 0.5); + + float sum = 0.0; + + // Approximate integral + const float dx = 0.001; + + for(float i = 0.0; i < 1.0; i += dx) { + float x = mix(X1, X2, i); + sum += flow(x) * falloff(x, X, Y) * dx; + } + + return min(uOpacity / 255.0, 2.0 * uStrokeRadiusMax * sum); +} diff --git a/libs/drawing-engine/src/programs/shaders/sourceMap.ts b/libs/drawing-engine/src/programs/shaders/sourceMap.ts index d7e904f..6a22897 100644 --- a/libs/drawing-engine/src/programs/shaders/sourceMap.ts +++ b/libs/drawing-engine/src/programs/shaders/sourceMap.ts @@ -2,7 +2,10 @@ import positionVertexSource from "./position.vertex.glsl" import colorFragmentSource from "./color.fragment.glsl" import textureVertexSource from "./texture.vertex.glsl" import textureFragmentSource from "./texture.fragment.glsl" +import brushVertexSource from "./brush.vertex.glsl" +import brushFragmentSource from "./brush.fragment.glsl" import normalizeCoords from "./inc/normalizeCoords.glsl" +import softBrush from "./inc/softBrush.glsl" /** * A map of shader source code. @@ -15,7 +18,10 @@ const sourceMap = { "color.fragment": colorFragmentSource, "texture.vertex": textureVertexSource, "texture.fragment": textureFragmentSource, + "brush.vertex": brushVertexSource, + "brush.fragment": brushFragmentSource, normalizeCoords, + softBrush, } export default sourceMap @@ -23,18 +29,23 @@ type NameMap = Record> // would be neat if I could run something to generate the uniformNames and attributeNames objects from the sourceMap -const includableScripts = { +const reusableUniforms = { normalizeCoords: { - source: normalizeCoords, - uniforms: { - canvasSize: "uCanvasSize", - }, + canvasSize: "uCanvasSize", + }, + softBrush: { + opacity: "uOpacity", + strokeRadiusMax: "uStrokeRadiusMax", + strokeRadiusMin: "uStrokeRadiusMin", + hardness: "uHardness", + flow: "uFlow", + strokeLength: "uStrokeLength", }, } as const export const uniformNames = { "position.vertex": { - ...includableScripts.normalizeCoords.uniforms, + ...reusableUniforms.normalizeCoords, }, "color.fragment": { color: "uColor", @@ -44,6 +55,14 @@ export const uniformNames = { foreground: "uForegroundTexture", background: "uBackgroundTexture", }, + "brush.vertex": { + ...reusableUniforms.normalizeCoords, + }, + "brush.fragment": { + color: "uColor", + isPremultipliedAlpha: "uIsPremultipliedAlpha", + ...reusableUniforms.softBrush, + }, } as const satisfies Readonly> export const attributeNames = { @@ -54,4 +73,8 @@ export const attributeNames = { position: "aPosition", textureCoordinates: "aTexcoord", }, + "brush.vertex": { + position: "aPosition", + bounds: "aBounds", + }, } as const satisfies Readonly>