diff --git a/src/core/p5.Renderer2D.js b/src/core/p5.Renderer2D.js index 6213b08228..d914c52439 100644 --- a/src/core/p5.Renderer2D.js +++ b/src/core/p5.Renderer2D.js @@ -5,8 +5,12 @@ import { Graphics } from './p5.Graphics'; import { Image } from '../image/p5.Image'; import { Element } from '../dom/p5.Element'; import { MediaElement } from '../dom/p5.MediaElement'; + +import FilterRenderer2D from '../image/filterRenderer2D'; + import { PrimitiveToPath2DConverter } from '../shape/custom_shapes'; + const styleEmpty = 'rgba(0,0,0,0)'; // const alphaThreshold = 0.00125; // minimum visible @@ -67,6 +71,9 @@ class Renderer2D extends Renderer { } this.scale(this._pixelDensity, this._pixelDensity); + if(!this.filterRenderer){ + this.filterRenderer = new FilterRenderer2D(this); + } // Set and return p5.Element this.wrappedElt = new Element(this.elt, this._pInst); diff --git a/src/image/const.js b/src/image/const.js new file mode 100644 index 0000000000..535ce66c31 --- /dev/null +++ b/src/image/const.js @@ -0,0 +1,6 @@ +import * as constants from '../core/constants'; +export const filterParamDefaults = { + [constants.BLUR]: 3, + [constants.POSTERIZE]: 4, + [constants.THRESHOLD]: 0.5, +}; diff --git a/src/image/filterRenderer2D.js b/src/image/filterRenderer2D.js new file mode 100644 index 0000000000..6a3f194466 --- /dev/null +++ b/src/image/filterRenderer2D.js @@ -0,0 +1,258 @@ +import { Shader } from "../webgl/p5.Shader"; +import { Texture } from "../webgl/p5.Texture"; +import { Image } from "./p5.Image"; +import * as constants from '../core/constants'; + +import filterGrayFrag from '../webgl/shaders/filters/gray.frag'; +import filterErodeFrag from '../webgl/shaders/filters/erode.frag'; +import filterDilateFrag from '../webgl/shaders/filters/dilate.frag'; +import filterBlurFrag from '../webgl/shaders/filters/blur.frag'; +import filterPosterizeFrag from '../webgl/shaders/filters/posterize.frag'; +import filterOpaqueFrag from '../webgl/shaders/filters/opaque.frag'; +import filterInvertFrag from '../webgl/shaders/filters/invert.frag'; +import filterThresholdFrag from '../webgl/shaders/filters/threshold.frag'; +import filterShaderVert from '../webgl/shaders/filters/default.vert'; +import { filterParamDefaults } from "./const"; + +class FilterRenderer2D { + /** + * Creates a new FilterRenderer2D instance. + * @param {p5} pInst - The p5.js instance. + */ + constructor(pInst) { + this.pInst = pInst; + // Create a canvas for applying WebGL-based filters + this.canvas = document.createElement('canvas'); + this.canvas.width = pInst.width; + this.canvas.height = pInst.height; + + // Initialize the WebGL context + this.gl = this.canvas.getContext('webgl'); + if (!this.gl) { + console.error("WebGL not supported, cannot apply filter."); + return; + } + // Minimal renderer object required by p5.Shader and p5.Texture + this._renderer = { + GL: this.gl, + registerEnabled: new Set(), + _curShader: null, + _emptyTexture: null, + webglVersion: 'WEBGL', + states: { + textureWrapX: this.gl.CLAMP_TO_EDGE, + textureWrapY: this.gl.CLAMP_TO_EDGE, + }, + _arraysEqual: (a, b) => JSON.stringify(a) === JSON.stringify(b), + _getEmptyTexture: () => { + if (!this._emptyTexture) { + const im = new Image(1, 1); + im.set(0, 0, 255); + this._emptyTexture = new Texture(this._renderer, im); + } + return this._emptyTexture; + }, + }; + + // Store the fragment shader sources + this.filterShaderSources = { + [constants.BLUR]: filterBlurFrag, + [constants.INVERT]: filterInvertFrag, + [constants.THRESHOLD]: filterThresholdFrag, + [constants.ERODE]: filterErodeFrag, + [constants.GRAY]: filterGrayFrag, + [constants.DILATE]: filterDilateFrag, + [constants.POSTERIZE]: filterPosterizeFrag, + [constants.OPAQUE]: filterOpaqueFrag, + }; + + // Store initialized shaders for each operation + this.filterShaders = {}; + + // These will be set by setOperation + this.operation = null; + this.filterParameter = 1; + this.customShader = null; + this._shader = null; + + // Create buffers once + this.vertexBuffer = this.gl.createBuffer(); + this.texcoordBuffer = this.gl.createBuffer(); + + // Set up the vertices and texture coordinates for a full-screen quad + this.vertices = new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]); + this.texcoords = new Float32Array([0, 1, 1, 1, 0, 0, 1, 0]); + + // Upload vertex data once + this._bindBufferData(this.vertexBuffer, this.gl.ARRAY_BUFFER, this.vertices); + + // Upload texcoord data once + this._bindBufferData(this.texcoordBuffer, this.gl.ARRAY_BUFFER, this.texcoords); + } + + /** + * Set the current filter operation and parameter. If a customShader is provided, + * that overrides the operation-based shader. + * @param {string} operation - The filter operation type (e.g., constants.BLUR). + * @param {number} filterParameter - The strength of the filter. + * @param {p5.Shader} customShader - Optional custom shader. + */ + setOperation(operation, filterParameter, customShader = null) { + this.operation = operation; + this.filterParameter = filterParameter; + + let useDefaultParam = operation in filterParamDefaults && filterParameter === undefined; + if (useDefaultParam) { + this.filterParameter = filterParamDefaults[operation]; + } + + this.customShader = customShader; + this._initializeShader(); + } + + /** + * Initializes or retrieves the shader program for the current operation. + * If a customShader is provided, that is used. + * Otherwise, returns a cached shader if available, or creates a new one, caches it, and sets it as current. + */ + _initializeShader() { + if (this.customShader) { + this._shader = this.customShader; + return; + } + + if (!this.operation) { + console.error("No operation set for FilterRenderer2D, cannot initialize shader."); + return; + } + + // If we already have a compiled shader for this operation, reuse it + if (this.filterShaders[this.operation]) { + this._shader = this.filterShaders[this.operation]; + return; + } + + const fragShaderSrc = this.filterShaderSources[this.operation]; + if (!fragShaderSrc) { + console.error("No shader available for this operation:", this.operation); + return; + } + + // Create and store the new shader + const newShader = new Shader(this._renderer, filterShaderVert, fragShaderSrc); + this.filterShaders[this.operation] = newShader; + this._shader = newShader; + } + + /** + * Binds a buffer to the drawing context + * when passed more than two arguments it also updates or initializes + * the data associated with the buffer + */ + _bindBufferData(buffer, target, values) { + const gl = this.gl; + gl.bindBuffer(target, buffer); + gl.bufferData(target, values, gl.STATIC_DRAW); + } + + get canvasTexture() { + if (!this._canvasTexture) { + this._canvasTexture = new Texture(this._renderer, this.pInst.wrappedElt); + } + return this._canvasTexture; + } + + /** + * Prepares and runs the full-screen quad draw call. + */ + _renderPass() { + const gl = this.gl; + this._shader.bindShader(); + const pixelDensity = this.pInst.pixelDensity ? this.pInst.pixelDensity() : 1; + + const texelSize = [ + 1 / (this.pInst.width * pixelDensity), + 1 / (this.pInst.height * pixelDensity) + ]; + + const canvasTexture = this.canvasTexture; + + // Set uniforms for the shader + this._shader.setUniform('tex0', canvasTexture); + this._shader.setUniform('texelSize', texelSize); + this._shader.setUniform('canvasSize', [this.pInst.width, this.pInst.height]); + this._shader.setUniform('radius', Math.max(1, this.filterParameter)); + this._shader.setUniform('filterParameter', this.filterParameter); + + this.pInst.states.rectMode = constants.CORNER; + this.pInst.states.imageMode = constants.CORNER; + this.pInst.blendMode(constants.BLEND); + this.pInst.resetMatrix(); + + + const identityMatrix = [1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + 0, 0, 0, 1]; + this._shader.setUniform('uModelViewMatrix', identityMatrix); + this._shader.setUniform('uProjectionMatrix', identityMatrix); + + // Bind and enable vertex attributes + gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer); + this._shader.enableAttrib(this._shader.attributes.aPosition, 2); + + gl.bindBuffer(gl.ARRAY_BUFFER, this.texcoordBuffer); + this._shader.enableAttrib(this._shader.attributes.aTexCoord, 2); + + this._shader.bindTextures(); + this._shader.disableRemainingAttributes(); + + // Draw the quad + gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); + // Unbind the shader + this._shader.unbindShader(); + } + + /** + * Applies the current filter operation. If the filter requires multiple passes (e.g. blur), + * it handles those internally. Make sure setOperation() has been called before applyFilter(). + */ + applyFilter() { + if (!this._shader) { + console.error("Cannot apply filter: shader not initialized."); + return; + } + this.pInst.push(); + this.pInst.resetMatrix(); + // For blur, we typically do two passes: one horizontal, one vertical. + if (this.operation === constants.BLUR && !this.customShader) { + // Horizontal pass + this._shader.setUniform('direction', [1, 0]); + this._renderPass(); + + // Draw the result onto itself + this.pInst.clear(); + this.pInst.drawingContext.drawImage(this.canvas, 0, 0, this.pInst.width, this.pInst.height); + + // Vertical pass + this._shader.setUniform('direction', [0, 1]); + this._renderPass(); + + this.pInst.clear(); + this.pInst.drawingContext.drawImage(this.canvas, 0, 0, this.pInst.width, this.pInst.height); + } else { + // Single-pass filters + + this._renderPass(); + this.pInst.clear(); + // con + this.pInst.blendMode(constants.BLEND); + + + this.pInst.drawingContext.drawImage(this.canvas, 0, 0, this.pInst.width, this.pInst.height); + } + this.pInst.pop(); + } +} + +export default FilterRenderer2D; diff --git a/src/image/index.js b/src/image/index.js index b017b05544..ce63a52bd4 100644 --- a/src/image/index.js +++ b/src/image/index.js @@ -2,10 +2,14 @@ import image from './image.js'; import loadingDisplaying from './loading_displaying.js'; import p5image from './p5.Image.js'; import pixels from './pixels.js'; +import shader from '../webgl/p5.Shader.js'; +import texture from '../webgl/p5.Texture.js'; export default function(p5){ p5.registerAddon(image); p5.registerAddon(loadingDisplaying); p5.registerAddon(p5image); p5.registerAddon(pixels); + p5.registerAddon(shader); + p5.registerAddon(texture); } diff --git a/src/image/pixels.js b/src/image/pixels.js index 78df6bd877..c2e2e10c6b 100644 --- a/src/image/pixels.js +++ b/src/image/pixels.js @@ -752,34 +752,14 @@ function pixels(p5, fn){ // when this is P2D renderer, create/use hidden webgl renderer else { - const filterGraphicsLayer = this.getFilterGraphicsLayer(); - // copy p2d canvas contents to secondary webgl renderer - // dest - filterGraphicsLayer.copy( - // src - this._renderer, - // src coods - 0, 0, this.width, this.height, - // dest coords - -this.width/2, -this.height/2, this.width, this.height - ); - //clearing the main canvas - this._renderer.clear(); - this._renderer.resetMatrix(); - // filter it with shaders - filterGraphicsLayer.filter(...args); + if (shader) { + this._renderer.filterRenderer.setOperation(operation, value, shader); + } else { + this._renderer.filterRenderer.setOperation(operation, value); + } - // copy secondary webgl renderer back to original p2d canvas - this.copy( - // src - filterGraphicsLayer._renderer, - // src coods - 0, 0, this.width, this.height, - // dest coords - 0, 0, this.width, this.height - ); - filterGraphicsLayer.clear(); // prevent feedback effects on p2d canvas + this._renderer.filterRenderer.applyFilter(); } }; diff --git a/src/webgl/material.js b/src/webgl/material.js index 4a42d117fe..1768e0cfaa 100644 --- a/src/webgl/material.js +++ b/src/webgl/material.js @@ -651,7 +651,7 @@ function material(p5, fn){ if (this._renderer.GL) { shader.ensureCompiledOnContext(this._renderer); } else { - shader.ensureCompiledOnContext(this._renderer.getFilterGraphicsLayer()._renderer); + shader.ensureCompiledOnContext(this); } return shader; }; diff --git a/src/webgl/p5.RendererGL.js b/src/webgl/p5.RendererGL.js index 8f407a5c2f..b929542869 100644 --- a/src/webgl/p5.RendererGL.js +++ b/src/webgl/p5.RendererGL.js @@ -14,6 +14,7 @@ import { Graphics } from "../core/p5.Graphics"; import { Element } from "../dom/p5.Element"; import { ShapeBuilder } from "./ShapeBuilder"; import { GeometryBufferCache } from "./GeometryBufferCache"; +import { filterParamDefaults } from '../image/const'; import lightingShader from "./shaders/lighting.glsl"; import webgl2CompatibilityShader from "./shaders/webgl2Compatibility.glsl"; @@ -1150,13 +1151,8 @@ class RendererGL extends Renderer { let operation = undefined; if (typeof args[0] === "string") { operation = args[0]; - let defaults = { - [constants.BLUR]: 3, - [constants.POSTERIZE]: 4, - [constants.THRESHOLD]: 0.5, - }; - let useDefaultParam = operation in defaults && args[1] === undefined; - filterParameter = useDefaultParam ? defaults[operation] : args[1]; + let useDefaultParam = operation in filterParamDefaults && args[1] === undefined; + filterParameter = useDefaultParam ? filterParamDefaults[operation] : args[1]; // Create and store shader for constants once on initial filter call. // Need to store multiple in case user calls different filters, diff --git a/src/webgl/p5.Shader.js b/src/webgl/p5.Shader.js index 2e3330ff0a..01256011df 100644 --- a/src/webgl/p5.Shader.js +++ b/src/webgl/p5.Shader.js @@ -625,11 +625,12 @@ class Shader { 'The shader being run is attached to a different context. Do you need to copy it to this context first with .copyToContext()?' ); } else if (this._glProgram === 0) { - this._renderer = context; + this._renderer = context?._renderer?.filterRenderer?._renderer || context; this.init(); } } - + + /** * Queries the active attributes for this shader and loads * their names and locations into the attributes array. diff --git a/src/webgl/p5.Texture.js b/src/webgl/p5.Texture.js index 7be2831076..11d5b85fe6 100644 --- a/src/webgl/p5.Texture.js +++ b/src/webgl/p5.Texture.js @@ -207,7 +207,7 @@ class Texture { // flag for update in a future frame. // if we don't do this, a paused video, for example, may not // send the first frame to texture memory. - data.setModified(true); + data.setModified && data.setModified(true); } } else if (this.isSrcP5Image) { // for an image, we only update if the modified field has been set, diff --git a/test/unit/visual/cases/webgl.js b/test/unit/visual/cases/webgl.js index 91bc52ddd2..fe676a0e8c 100644 --- a/test/unit/visual/cases/webgl.js +++ b/test/unit/visual/cases/webgl.js @@ -70,6 +70,23 @@ visualSuite('WebGL', function() { screenshot(); } ); + + for (const mode of ['webgl', '2d']) { + visualSuite(`In ${mode} mode`, function() { + visualTest('It can combine multiple filter passes', function(p5, screenshot) { + p5.createCanvas(50, 50, mode === 'webgl' ? p5.WEBGL : p5.P2D); + if (mode === 'webgl') p5.translate(-p5.width/2, -p5.height/2); + p5.background(255); + p5.fill(0); + p5.noStroke(); + p5.circle(15, 15, 20); + p5.circle(30, 30, 20); + p5.filter(p5.BLUR, 5); + p5.filter(p5.THRESHOLD); + screenshot(); + }); + }); + } }); visualSuite('Lights', function() { diff --git a/test/unit/visual/screenshots/WebGL/filter/In 2d mode/It can combine multiple filter passes/000.png b/test/unit/visual/screenshots/WebGL/filter/In 2d mode/It can combine multiple filter passes/000.png new file mode 100644 index 0000000000..9679aa86dd Binary files /dev/null and b/test/unit/visual/screenshots/WebGL/filter/In 2d mode/It can combine multiple filter passes/000.png differ diff --git a/test/unit/visual/screenshots/WebGL/filter/In 2d mode/It can combine multiple filter passes/metadata.json b/test/unit/visual/screenshots/WebGL/filter/In 2d mode/It can combine multiple filter passes/metadata.json new file mode 100644 index 0000000000..2d4bfe30da --- /dev/null +++ b/test/unit/visual/screenshots/WebGL/filter/In 2d mode/It can combine multiple filter passes/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 1 +} \ No newline at end of file diff --git a/test/unit/visual/screenshots/WebGL/filter/In webgl mode/It can combine multiple filter passes/000.png b/test/unit/visual/screenshots/WebGL/filter/In webgl mode/It can combine multiple filter passes/000.png new file mode 100644 index 0000000000..ed3273a6a6 Binary files /dev/null and b/test/unit/visual/screenshots/WebGL/filter/In webgl mode/It can combine multiple filter passes/000.png differ diff --git a/test/unit/visual/screenshots/WebGL/filter/In webgl mode/It can combine multiple filter passes/metadata.json b/test/unit/visual/screenshots/WebGL/filter/In webgl mode/It can combine multiple filter passes/metadata.json new file mode 100644 index 0000000000..2d4bfe30da --- /dev/null +++ b/test/unit/visual/screenshots/WebGL/filter/In webgl mode/It can combine multiple filter passes/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 1 +} \ No newline at end of file diff --git a/test/unit/webgl/p5.RendererGL.js b/test/unit/webgl/p5.RendererGL.js index 9c67afa893..b857e1f904 100644 --- a/test/unit/webgl/p5.RendererGL.js +++ b/test/unit/webgl/p5.RendererGL.js @@ -412,18 +412,6 @@ suite('p5.RendererGL', function() { assert.doesNotThrow(testDefaultParams, 'this should not throw'); }); - test('filter() uses WEBGL implementation behind main P2D canvas', function() { - let renderer = myp5.createCanvas(3,3); - myp5.filter(myp5.BLUR); - assert.isDefined(renderer.filterGraphicsLayer); - }); - - test('filter() can opt out of WEBGL implementation', function() { - let renderer = myp5.createCanvas(3,3); - myp5.filter(myp5.BLUR, false); - assert.isUndefined(renderer.filterGraphicsLayer); - }); - test('filters make changes to canvas', function() { myp5.createCanvas(20,20); myp5.circle(10,10,12); @@ -554,6 +542,7 @@ suite('p5.RendererGL', function() { suite('external context', function() { const cases = [ + ['no modification', () => {}], ['corner rectMode', () => myp5.rectMode(myp5.CORNER)], ['corners rectMode', () => myp5.rectMode(myp5.CORNERS)], ['center rectMode', () => myp5.rectMode(myp5.CENTER)],