diff --git a/docs/parameterData.json b/docs/parameterData.json index d8a0c9881f..52f44826c2 100644 --- a/docs/parameterData.json +++ b/docs/parameterData.json @@ -1820,6 +1820,21 @@ ] ] }, + "buildColorFieldShader": { + "overloads": [ + [ + "Function", + "Object?" + ] + ] + }, + "colorField": { + "overloads": [ + [ + "p5.Shader" + ] + ] + }, "createSlider": { "overloads": [ [ diff --git a/src/core/p5.Renderer3D.js b/src/core/p5.Renderer3D.js index 280aa31c28..418841d8f6 100644 --- a/src/core/p5.Renderer3D.js +++ b/src/core/p5.Renderer3D.js @@ -1160,6 +1160,27 @@ export class Renderer3D extends Renderer { this.pop(); } + colorField(fieldShader) { + // Draws into whatever is currently being drawn to (the canvas, or the + // active framebuffer). Unlike filter(), nothing is read back from the + // target, so no intermediate framebuffer is needed. + const target = this.activeFramebuffer() || this; + this.push(); + this.states.setValue('strokeColor', null); + // The plane is skipped when there's no fill color (e.g. after noFill()) + this.fill(0); + target.filterCamera._resize(); + this.setCamera(target.filterCamera); + this.resetMatrix(); + this.clearDepth(); + this.noLights(); + this.shader(fieldShader); + this.plane(target.width, target.height); + // Don't let the full-screen plane affect geometry drawn afterwards + this.clearDepth(); + this.pop(); + } + // Pass this off to the host instance so that we can treat a renderer and a // framebuffer the same in filter() diff --git a/src/webgl/material.js b/src/webgl/material.js index b2f1a3f772..82e569cd53 100644 --- a/src/webgl/material.js +++ b/src/webgl/material.js @@ -746,6 +746,95 @@ function material(p5, fn) { }; p5.registerDecorator('p5.prototype.buildFilterShader', markExperimental('p5.strands', p5)); + /** + * Creates a p5.Shader that colors every pixel of + * the canvas based on its coordinates, to be used with + * `colorField()`. + * + * Use it for things like gradients, noise and warping, where each pixel's + * color depends on where it is rather than on what was already drawn. + * Unlike a filter, a color field doesn't read the existing canvas. + * + * Pass a function that uses p5.strands. Inside it, call + * `fieldColor.begin()`, read the pixel's normalized position from + * `fieldColor.texCoord`, pass the resulting color to `fieldColor.set()`, + * then call `fieldColor.end()`. + * + * ```js + * let myField; + * + * function setup() { + * createCanvas(100, 100, WEBGL); + * myField = buildColorFieldShader(gradient); + * } + * + * function gradient() { + * fieldColor.begin(); + * fieldColor.set([fieldColor.texCoord, 0, 1]); + * fieldColor.end(); + * } + * + * function draw() { + * colorField(myField); + * } + * ``` + * + * @method buildColorFieldShader + * @beta + * @submodule p5.strands + * @param {Function} callback A function building a p5.strands shader. + * @param {Object} [scope] An optional scope object passed to .modify(). + * @returns {p5.Shader} The color field shader. + */ + fn.buildColorFieldShader = function (callback, scope) { + this._assert3d('buildColorFieldShader'); + return this._renderer.baseColorFieldShader().modify(callback, scope); + }; + p5.registerDecorator('p5.prototype.buildColorFieldShader', markExperimental('p5.strands', p5)); + + /** + * Fills the whole canvas, or the active + * p5.Framebuffer, using a color field shader + * made with `buildColorFieldShader()`. + * + * The field is drawn independently of the camera and any transformations. + * It follows the current `blendMode()`, so + * fields that use transparency can be layered. Geometry drawn afterwards is + * not affected by the field's depth. + * + * @method colorField + * @beta + * @param {p5.Shader} fieldShader A shader made with `buildColorFieldShader()`. + * + * @example + *
+ * + * let myField; + * + * function setup() { + * createCanvas(100, 100, WEBGL); + * myField = buildColorFieldShader(gradient); + * describe('A gradient that fades from black at the top left to blue and yellow.'); + * } + * + * function gradient() { + * fieldColor.begin(); + * fieldColor.set([fieldColor.texCoord, 0, 1]); + * fieldColor.end(); + * } + * + * function draw() { + * colorField(myField); + * } + * + *
+ */ + fn.colorField = function (fieldShader) { + this._assert3d('colorField'); + this._renderer.colorField(fieldShader); + return this; + }; + /** * Creates a p5.Shader object to be used with the * filter() function using GLSL. diff --git a/src/webgl/p5.RendererGL.js b/src/webgl/p5.RendererGL.js index c3e6d344c9..a91bf9f08c 100644 --- a/src/webgl/p5.RendererGL.js +++ b/src/webgl/p5.RendererGL.js @@ -36,6 +36,7 @@ import imageLightVert from './shaders/imageLight.vert'; import imageLightDiffusedFrag from './shaders/imageLightDiffused.frag'; import imageLightSpecularFrag from './shaders/imageLightSpecular.frag'; import filterBaseFrag from './shaders/filters/base.frag'; +import colorFieldBaseFrag from './shaders/field/base.frag'; const { lineDefs } = getStrokeDefs((n, v) => `#define ${n} ${v}\n`); @@ -55,7 +56,8 @@ const defaultShaders = { imageLightDiffusedFrag, imageLightSpecularFrag, filterBaseVert, - filterBaseFrag + filterBaseFrag, + colorFieldBaseFrag }; for (const key in defaultShaders) { defaultShaders[key] = webgl2CompatibilityShader + defaultShaders[key]; @@ -932,6 +934,32 @@ class RendererGL extends Renderer3D { return this._baseFilterShader; } + baseColorFieldShader() { + if (!this._baseColorFieldShader) { + // Reuses the filter vertex shader so that a field is drawn through the + // same (default) camera setup as filter() + this._baseColorFieldShader = new Shader( + this, + this._webGL2CompatibilityPrefix('vert', 'highp') + + defaultShaders.filterBaseVert, + this._webGL2CompatibilityPrefix('frag', 'highp') + + defaultShaders.colorFieldBaseFrag, + { + vertex: {}, + fragment: { + 'vec4 getColor': `(FieldInputs inputs) { + return vec4(0.0, 0.0, 0.0, 1.0); + }` + }, + hookAliases: { + getColor: ['fieldColor'] + } + } + ); + } + return this._baseColorFieldShader; + } + _webGL2CompatibilityPrefix(shaderType, floatPrecision) { let code = ''; if (this.webglVersion === constants.WEBGL2) { diff --git a/src/webgl/shaders/field/base.frag b/src/webgl/shaders/field/base.frag new file mode 100644 index 0000000000..db47fc83b6 --- /dev/null +++ b/src/webgl/shaders/field/base.frag @@ -0,0 +1,15 @@ +precision highp float; + +IN vec2 vTexCoord; + +struct FieldInputs { + vec2 texCoord; +}; + +void main(void) { + FieldInputs inputs; + inputs.texCoord = vTexCoord; + OUT_COLOR = HOOK_getColor(inputs); + // Premultiply alpha so the field blends like other p5 draws + OUT_COLOR.rgb *= OUT_COLOR.a; +} diff --git a/src/webgpu/p5.RendererWebGPU.js b/src/webgpu/p5.RendererWebGPU.js index 8e1e1e9c32..515f7073a3 100644 --- a/src/webgpu/p5.RendererWebGPU.js +++ b/src/webgpu/p5.RendererWebGPU.js @@ -22,6 +22,10 @@ import { baseFilterVertexShader, baseFilterFragmentShader } from './shaders/filters/base'; +import { + baseColorFieldVertexShader, + baseColorFieldFragmentShader +} from './shaders/field/base'; import { imageLightVertexShader, imageLightDiffusedFragmentShader, @@ -5042,6 +5046,28 @@ ${hookUniformFields}} return this._baseFilterShader; } + baseColorFieldShader() { + if (!this._baseColorFieldShader) { + this._baseColorFieldShader = new Shader( + this, + baseColorFieldVertexShader, + baseColorFieldFragmentShader, + { + vertex: {}, + fragment: { + 'vec4 getColor': `(inputs: FieldInputs) -> vec4 { + return vec4(0.0, 0.0, 0.0, 1.0); + }` + }, + hookAliases: { + getColor: ['fieldColor'] + } + } + ); + } + return this._baseColorFieldShader; + } + baseComputeShader() { if (!this._baseComputeShader) { this._baseComputeShader = new Shader(this, baseComputeShader, { diff --git a/src/webgpu/shaders/field/base.js b/src/webgpu/shaders/field/base.js new file mode 100644 index 0000000000..39e50df13b --- /dev/null +++ b/src/webgpu/shaders/field/base.js @@ -0,0 +1,80 @@ +const fieldUniforms = ` +// Group 0: Field Properties +struct FieldUniforms { + canvasSize: vec2, +} + +// Group 1: Model Transform +struct ModelUniforms { + uModelViewMatrix: mat4x4, +} + +// Group 2: Camera and Projection +struct CameraUniforms { + uProjectionMatrix: mat4x4, +} + +@group(0) @binding(0) var fieldParams: FieldUniforms; +@group(1) @binding(0) var model: ModelUniforms; +@group(2) @binding(0) var camera: CameraUniforms; +`; + +export const baseColorFieldVertexShader = + fieldUniforms + + ` +struct VertexInput { + @location(0) aPosition: vec3, + @location(1) aTexCoord: vec2, +} + +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) vTexCoord: vec2, +} + +@vertex +fn main(input: VertexInput) -> VertexOutput { + var output: VertexOutput; + + // transferring texcoords for the frag shader + output.vTexCoord = input.aTexCoord; + + // copy position with a fourth coordinate for projection (1.0 is normal) + let positionVec4 = vec4(input.aPosition, 1.0); + + // project to 3D space + output.position = camera.uProjectionMatrix * model.uModelViewMatrix * positionVec4; + + return output; +} +`; + +export const baseColorFieldFragmentShader = + fieldUniforms + + ` +struct FieldInputs { + texCoord: vec2, +} + +struct FragmentInput { + @location(0) vTexCoord: vec2, +} + +struct FragmentOutput { + @location(0) color: vec4, +} + +@fragment +fn main(input: FragmentInput) -> FragmentOutput { + var output: FragmentOutput; + var inputs: FieldInputs; + inputs.texCoord = input.vTexCoord; + + var outColor = HOOK_getColor(inputs); + // Premultiply alpha so the field blends like other p5 draws + outColor = vec4(outColor.rgb * outColor.a, outColor.a); + output.color = outColor; + + return output; +} +`; diff --git a/test/unit/visual/cases/webgl.js b/test/unit/visual/cases/webgl.js index 96afb6bf12..119ca67882 100644 --- a/test/unit/visual/cases/webgl.js +++ b/test/unit/visual/cases/webgl.js @@ -1685,6 +1685,56 @@ visualSuite('WebGL', function () { ); }); + visualSuite('colorField', function () { + visualTest('On the main canvas', function (p5, screenshot) { + p5.createCanvas(50, 50, p5.WEBGL); + const shader = p5.buildColorFieldShader( + () => { + p5.fieldColor.begin(); + p5.fieldColor.set([p5.fieldColor.texCoord, 0, 1]); + p5.fieldColor.end(); + }, + { p5 } + ); + p5.colorField(shader); + screenshot(); + }); + + visualTest('On a framebuffer', function (p5, screenshot) { + p5.createCanvas(50, 50, p5.WEBGL); + const shader = p5.buildColorFieldShader( + () => { + p5.fieldColor.begin(); + p5.fieldColor.set([p5.fieldColor.texCoord, 0, 1]); + p5.fieldColor.end(); + }, + { p5 } + ); + const fbo = p5.createFramebuffer(); + fbo.begin(); + p5.colorField(shader); + fbo.end(); + p5.imageMode(p5.CENTER); + p5.image(fbo, 0, 0); + screenshot(); + }); + + visualTest('Blends with transparency', function (p5, screenshot) { + p5.createCanvas(50, 50, p5.WEBGL); + const shader = p5.buildColorFieldShader( + () => { + p5.fieldColor.begin(); + p5.fieldColor.set([1, 0, 0, p5.fieldColor.texCoord.x]); + p5.fieldColor.end(); + }, + { p5 } + ); + p5.background(0, 0, 255); + p5.colorField(shader); + screenshot(); + }); + }); + visualSuite('p5.strands', () => { visualTest('it recovers from p5.strands errors', (p5, screenshot) => { p5.createCanvas(50, 50, p5.WEBGL); diff --git a/test/unit/visual/cases/webgpu.js b/test/unit/visual/cases/webgpu.js index f9f30887f4..22fcc13d37 100644 --- a/test/unit/visual/cases/webgpu.js +++ b/test/unit/visual/cases/webgpu.js @@ -265,6 +265,20 @@ visualSuite('WebGPU', function () { await screenshot(); }); + visualTest('colorField on the main canvas', async function (p5, screenshot) { + await p5.createCanvas(50, 50, p5.WEBGPU); + const field = p5.buildColorFieldShader( + () => { + p5.fieldColor.begin(); + p5.fieldColor.set([p5.fieldColor.texCoord, 0, 1]); + p5.fieldColor.end(); + }, + { p5 } + ); + p5.colorField(field); + await screenshot(); + }); + visualTest('filter shaders with flat API', async function (p5, screenshot) { await p5.createCanvas(50, 50, p5.WEBGPU); p5.background(255); diff --git a/test/unit/visual/screenshots/WebGL/colorField/Blends with transparency/000.png b/test/unit/visual/screenshots/WebGL/colorField/Blends with transparency/000.png new file mode 100644 index 0000000000..d5b801ee65 Binary files /dev/null and b/test/unit/visual/screenshots/WebGL/colorField/Blends with transparency/000.png differ diff --git a/test/unit/visual/screenshots/WebGL/colorField/Blends with transparency/metadata.json b/test/unit/visual/screenshots/WebGL/colorField/Blends with transparency/metadata.json new file mode 100644 index 0000000000..2d4bfe30da --- /dev/null +++ b/test/unit/visual/screenshots/WebGL/colorField/Blends with transparency/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 1 +} \ No newline at end of file diff --git a/test/unit/visual/screenshots/WebGL/colorField/On a framebuffer/000.png b/test/unit/visual/screenshots/WebGL/colorField/On a framebuffer/000.png new file mode 100644 index 0000000000..460f144e68 Binary files /dev/null and b/test/unit/visual/screenshots/WebGL/colorField/On a framebuffer/000.png differ diff --git a/test/unit/visual/screenshots/WebGL/colorField/On a framebuffer/metadata.json b/test/unit/visual/screenshots/WebGL/colorField/On a framebuffer/metadata.json new file mode 100644 index 0000000000..2d4bfe30da --- /dev/null +++ b/test/unit/visual/screenshots/WebGL/colorField/On a framebuffer/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 1 +} \ No newline at end of file diff --git a/test/unit/visual/screenshots/WebGL/colorField/On the main canvas/000.png b/test/unit/visual/screenshots/WebGL/colorField/On the main canvas/000.png new file mode 100644 index 0000000000..460f144e68 Binary files /dev/null and b/test/unit/visual/screenshots/WebGL/colorField/On the main canvas/000.png differ diff --git a/test/unit/visual/screenshots/WebGL/colorField/On the main canvas/metadata.json b/test/unit/visual/screenshots/WebGL/colorField/On the main canvas/metadata.json new file mode 100644 index 0000000000..2d4bfe30da --- /dev/null +++ b/test/unit/visual/screenshots/WebGL/colorField/On the main canvas/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 1 +} \ No newline at end of file diff --git a/test/unit/visual/screenshots/WebGPU/Shaders/colorField on the main canvas/000.png b/test/unit/visual/screenshots/WebGPU/Shaders/colorField on the main canvas/000.png new file mode 100644 index 0000000000..460f144e68 Binary files /dev/null and b/test/unit/visual/screenshots/WebGPU/Shaders/colorField on the main canvas/000.png differ diff --git a/test/unit/visual/screenshots/WebGPU/Shaders/colorField on the main canvas/metadata.json b/test/unit/visual/screenshots/WebGPU/Shaders/colorField on the main canvas/metadata.json new file mode 100644 index 0000000000..2d4bfe30da --- /dev/null +++ b/test/unit/visual/screenshots/WebGPU/Shaders/colorField on the main canvas/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 1 +} \ No newline at end of file diff --git a/test/unit/webgl/p5.Shader.js b/test/unit/webgl/p5.Shader.js index c5d5b8d3bf..adcf8404f6 100644 --- a/test/unit/webgl/p5.Shader.js +++ b/test/unit/webgl/p5.Shader.js @@ -506,6 +506,55 @@ suite('p5.Shader', function () { }).not.toThrowError(); }); + test('buildColorFieldShader colors the canvas from texCoord', () => { + myp5.createCanvas(5, 5, myp5.WEBGL); + myp5.pixelDensity(1); + const myShader = myp5.buildColorFieldShader(() => { + fieldColor.begin(); + fieldColor.set([1, 0, 0, 1]); + fieldColor.end(); + }); + myp5.background(0); + myp5.colorField(myShader); + myp5.loadPixels(); + expect(Array.from(myp5.pixels.slice(0, 4))).toEqual([255, 0, 0, 255]); + }); + + test('colorField does not leave depth behind for later geometry', () => { + myp5.createCanvas(5, 5, myp5.WEBGL); + myp5.pixelDensity(1); + const myShader = myp5.buildColorFieldShader(() => { + fieldColor.begin(); + fieldColor.set([1, 0, 0, 1]); + fieldColor.end(); + }); + myp5.background(0); + myp5.colorField(myShader); + myp5.noStroke(); + myp5.fill(0, 0, 255); + // Slightly behind the field's plane, so it only shows if the field's + // depth was cleared afterwards + myp5.translate(0, 0, -1); + myp5.plane(myp5.width * 2, myp5.height * 2); + myp5.loadPixels(); + expect(Array.from(myp5.pixels.slice(0, 4))).toEqual([0, 0, 255, 255]); + }); + + test('colorField still draws after noFill()', () => { + myp5.createCanvas(5, 5, myp5.WEBGL); + myp5.pixelDensity(1); + const myShader = myp5.buildColorFieldShader(() => { + fieldColor.begin(); + fieldColor.set([1, 0, 0, 1]); + fieldColor.end(); + }); + myp5.background(0); + myp5.noFill(); + myp5.colorField(myShader); + myp5.loadPixels(); + expect(Array.from(myp5.pixels.slice(0, 4))).toEqual([255, 0, 0, 255]); + }); + test('accepts an arrow function with an unparenthesized parameter', () => { myp5.createCanvas(5, 5, myp5.WEBGL); expect(() => {