diff --git a/contributor_docs/p5.strands.md b/contributor_docs/p5.strands.md index e7a60227e9..74ca130a7f 100644 --- a/contributor_docs/p5.strands.md +++ b/contributor_docs/p5.strands.md @@ -1,14 +1,29 @@ - + # p5.strands Overview -Shader programming is an area of creative coding that can feel like a dark art to many. People share lots of stunning visuals that are created with shaders, but shaders feel like a completely different way of coding, requiring you to learn a new language, pipeline, and paradigm. +p5.strands is a shader programming environment that p5.js provides, starting from p5 version 2. It aims to put the visual opportunities uniquely afforded by shaders within reach of people familiar with p5 without a huge learning curve. It does so by letting you write shader code in JavaScript, and by letting you focus on small changes rather than requiring a full rendering pipeline. The specifics of these APIs are still changing: a valuable contribution to the project is to give us feedback on what parts are still awkward or confusing and need smoothing out! -p5.strands hopes to address all of those issues by letting you write shader snippets in JavaScript and compiling it to OpenGL Shading Language (GLSL) for you! +If you're looking to start writing p5.strands shaders yourself, take a look at our p5.strands tutorial or the examples in the reference for the p5.js base shaders. The rest of this document will describe how p5.strands works behind the scenes. If you are interested in contributing to the p5.strands, read on! -If you're looking to start writing p5.strands shaders yourself, take a look at our p5.strands tutorial or the examples in the reference for the p5.js base shaders. The rest of this document will describe how p5.strands works behind the scenes. If you are interested in contributing to the p5.strands codebase, read on! +## Project goals -## Code processing pipeline +p5.strands aims to address a number of issues at once: + +- **Learning shaders**: traditional shader programming is notoriously difficult to get into, requiring you to learn a new language, a parallel programming paradigm, and an execution pipeline before you can truly get started. p5.strands aims to create an environment as familiar as possible for those used to non-shader programming in p5.js, and requiring you to learn incrementally only what is relevant for your task at hand. It should do so without being just a "toy" environment; you should be able to use p5.strands in your work or art in p5. +- **Teaching shaders**: to teach shader programming, one often ends up building *scaffolding* so that you may abstract away the many concepts and boilerplate code required to get to an output, and then slowly remove the scaffolding as you teach. p5.strands is built with this in mind as an alternative to building this yourself, with further development of code educational materials built in. +- **Using custom shaders**: while one could always provide custom shaders to extend the rendering capabilities of p5, it was difficult to integrate new shaders into the rest of p5's systems (working with spatial transformations, lighting, etc.) without both an intimate knowledge of internal p5 code, and the time to keep up to changes to those internals between versions to keep integrations working. p5.strands as a system lets you make *updates* to the built-in shaders without replacing them from scratch or knowing about anything beyond the part of the pipeline you are taking over. +- **Futureproofing code**: today, most shaders are written for WebGL. Development on WebGL as a technology is decelerating as momentum on WebGPU, its successor, increases, where new functionality and programming paradigms are being introduced in a new shader programming language. p5.strands offers a way to target both systems with the same code to smooth the transition between technologies, and offer an openings to experiment with the new parts of WebGPU without having to completely start from scratch. + +## What needs feedback + +The main thing you can do to help out is to test out p5.strands and let us know what parts are the hardest to understand, and what parts are the most difficult to use: what took longest to figure out, where did you get tripped up along the way, etc. We continue to iterate on the programming interface to try to make this easier. + +At this point, the general technical framework has been set up (see the technical overview below), but may still include implementation bugs. Testing and reporting these bugs (and helping fix them if you are up for it!) helps the project reach maturity. + +Finally, since the system is still so new and is still changing, it lacks extensive tutorials and examples. If you want to write or make videos to help people understand how to use p5.strands, or show examples illustrating the kinds of things you can make with it, reach out! + +## Technical overview At its core, p5.strands works in four steps: @@ -17,7 +32,109 @@ At its core, p5.strands works in four steps: 3. The transpiled code is run. Variable modification function calls are tracked in a graph data structure. 4. p5.strands generates GLSL code from that graph. -## Why pseudo-JavaScript? +### What functionality is available in JavaScript? + +We expose JavaScript versions of the following GLSL functions in [`strands_builtins.js`](https://github.com/processing/p5.js/blob/main/src/strands/strands_builtins.js): +- Trigonometry + - `acos()` + - `acosh()` + - `asin()` + - `asinh()` + - `atan()` + - `atanh()` + - `cos()` + - `cosh()` + - `degrees()` + - `radians()` + - `sin()` + - `sinh()` + - `tan()` + - `tanh()` +- Math + - `abs()` + - `ceil()` + - `clamp()` + - `dFdx()` + - `dFdy()` + - `exp()` + - `exp2()` + - `floor()` + - `fma()` + - `fract()` + - `fwidth()` + - `inversesqrt()` + - `log()` + - `log2()` + - `max()` + - `min()` + - `mix()` + - `mod()` + - `pow()` + - `round()` + - `roundEven()` + - `sign()` + - `smoothstep()` + - `sqrt()` + - `step()` + - `trunc()` +- Vector + - `cross()` + - `distance()` + - `dot()` + - `equal()` + - `faceForward()` + - `length()` + - `normalize()` + - `notEqual()` + - `reflect()` + - `refract()` +- Color + - `texture()` +- State + - `gl_InstanceID` via `instanceIndex` + +In [`strands_api.js`](https://github.com/processing/p5.js/blob/main/src/strands/strands_api.js), we make the following p5 functionality available in strands shaders: + +- Environment + - `width` + - `height` + - `mouseX` + - `mouseY` + - `pmouseX` + - `pmouseY` + - `winMouseX` + - `winMouseY` + - `pwinMouseX` + - `pwinMouseY` + - `frameCount` + - `deltaTime` + - `displayWidth` + - `displayHeight` + - `windowWidth` + - `windowHeight` + - `mouseIsPressed` + - `millis()` +- Math + - `lerp()` + - `map()` +- Color + - `color()` + - `lerpColor()` + - `red()` + - `green()` + - `blue()` + - `alpha()` + - `saturation()` + - `brightness()` + - `lightness()` +- Random + - `random()` + - `randomSeed()` + - `randomGaussian()` + - `noise()` + - `noiseDetail()` + +### Why pseudo-JavaScript? The code the user writes when using p5.strands is mostly JavaScript, with some extensions. Shader code heavily encourages use of vectors, and the extensions all make this as easy in JavaScript as in GLSL. @@ -55,7 +172,7 @@ baseMaterialShader().modify(() => { }); ``` -## The program graph +### The program graph The overall structure of a shader program is represented by a **control-flow graph (CFG)**. This divides up a program into chunks that need to be outputted in linear order based on control flow. A program like the one below would get chunked up around the if statement: @@ -137,7 +254,7 @@ a_0-->n0[0] Each node in the DAG belongs to a chunk in the CFG. This helps us keep track of key points in the code. If we need to, for example, generate a temporary variable at the end of an if statement, we can refer to that CFG chunk rather than whatever the last value node in the if statement happens to be. -## Control flow +### Control flow p5.strands has to convert any control flow that should show up in GLSL into function calls instead of JavaScript keywords. If we don't, they run in JavaScript, and are invisible to GLSL generation. For example, if you had a loop that runs 10 times that adds 1 each time, it would output the add 1 line 10 times rather than outputting a for loop. @@ -290,7 +407,7 @@ We use a special kind of node in the DAG called a **phi node**, something used i In the CFG, we surround chunks producing phi nodes by a `BRANCH` and a `MERGE` chunk. In the `BRANCH` chunk, we can initialize phi nodes, sometimes giving them initial values. In the `MERGE` chunk, the value of the phi node has stabilized, and other nodes can use them as a dependency. -## GLSL generation +### GLSL generation GLSL is currently the only output format we support, but p5.strands is designed to be able to generate multiple formats. Specifically, in WebGPU, they use the WebGPU Shading Language (WGSL). Our goal is that your same JavaScript p5.strands code can be used in WebGL or WebGPU without you having to do any modifications. diff --git a/contributor_docs/webgpu.md b/contributor_docs/webgpu.md index b9b402b40a..c4ab1a9c2f 100644 --- a/contributor_docs/webgpu.md +++ b/contributor_docs/webgpu.md @@ -1,8 +1,10 @@ -p5.js has recently added an experimental WebGPU mode. It is a 3D-capable renderer like WebGL mode, and supports all the functions available in WebGL mode, but has been built using different underlying technology that will help p5.js stay up-to-date as browsers evolve. +# p5.js WebGPU mode -It's still in the early days, so we would love for people to test it out, give feedback, and get involved! +The WebGPU mode of p5.js is an experimental 3D-capable renderer, like WebGL mode, that supports all the functions available in WebGL mode, but that has been built using different underlying technology, WebGPU. The older WebGL technology will not be going away, but it seems clear that browser makers and standards bodies are slowing development on WebGL as adoption and functionality of WebGPU increases. WebGPU adds new programming paradigms on top of what WebGL provided in the form of *compute shaders.* Getting p5's WebGPU system ready will help p5.js stay up-to-date as browsers and programming paradigms evolve. + +WebGPU mode in p5 is still in the early days, so we would love for people to test it out, give feedback, and get involved! This can come in the form of testing existing p5 functionality to help verify that it works correctly, helping figure out friendly p5 APIs for new WebGPU functionality, and helping create examples and learning resources to guide future users and contributors. ## Using WebGPU mode @@ -38,7 +40,8 @@ We'd love to have more people involved with WebGPU mode! Here are some ways you - Test it out! Let us know what bugs you encounter by filing issues on GitHub. - Help us optimize the new rendering system. The first step is also testing: what parts are faster or slower than the more stable WebGL mode? Based on that, we can decide on changes to the rendering system to address those issues and implement them in the codebase. -- Brainstorm new ideas! There are new capabilities in the WebGPU spec that we can bring to p5, such as compute shaders. Talk to us on Discord about what you'd love your code to look like when creating, for example, a particle system on the GPU, and we can see how we can build an API around that. +- Brainstorm new ideas! There are new capabilities in the WebGPU spec that we can bring to p5, such as compute shaders (see the compute shaders section below). Talk to us on Discord about what you'd love your code to look like when creating, for example, a particle system on the GPU, and we can see how we can build an API around that. +- Help create resources for other users of WebGPU mode by writing tutorials and examples! ## Goals @@ -92,3 +95,9 @@ Entities that are shared by all 3D renderers such as `p5.Geometry`, `p5.Framebuf While WebGL mode submits all draw commands immediately, WebGPU mode defers submitting until the last possible moment so that it can submit draw commands in batches. Rather than drawing, commands are built up in an array and `_hasPendingDraws` is set to `true`. In `finishDraw`, called at the end of each frame, these are all finally submitted to the GPU as one render pass. There are a few other times where they get submitted early in other render passes. When switching draw targets, such as when drawing to a framebuffer, pending draws are submitted in a render pass too. This makes sure that you can then read from the framebuffer safely in the next render pass. We also submit a render pass when you call `loadPixels` or another function that involves reading back data from the GPU. Since draws get batched up, this means that buffers used to send shader uniform values to the GPU cannot be shared. If they were shared, they would get rewritten by the next thing getting drawn before the previous one gets to the GPU! Instead, we build up a pool of buffers that we can pull from for shader uniforms and vertex information. + +### Compute shaders + +This is the main area where WebGPU as a technology diverges from WebGL. Compute shaders let you do arbitrary computation on the GPU (not just moving vertices or shading pixels), such as updating the state of a particle system, so that you can take advantage of the parallel computation of the GPU and also avoid slow data transfer from the CPU to the GPU when you do eventually need to draw to the screen. + +In p5.js, this is integrated into p5.strands via the `buildComputeShader` function to create a compute shader, and `compute(function, count)` to run it. We are still figuring out the specifics of how best to expose this functionality. Should we create higher level abstractions around compute shaders so they feel more familiar to previous p5 paradigms? Should we provide more small functions and data structures to use in compute shaders to make them easier to write? Should we make p5.strands wrappers for every new part of WGSL shader functionality? We're still figuring that out incrementally. We would love for you to be part of that conversation! diff --git a/src/core/experimental.js b/src/core/experimental.js new file mode 100644 index 0000000000..63092dc97a --- /dev/null +++ b/src/core/experimental.js @@ -0,0 +1,63 @@ +import { FES } from '../friendly_errors/fes'; + + +/* + * Sometimes p5.js includes experimental functionality whose APIs may + * change in the future, but for which we want more community feedback + * and testing. To be able to include these in a release, we need to: + * - Create a name for the subject area, e.g. 'webgpu' + * - Write a document in the contributor_docs folder for the subject area + * describing its goals and what we want feedback on. The file should match + * the subject area name, plus the .md suffix. + * - Write a message below that will show up in the console when functionality + * from that subject area. A link to the doc will be automatically appended. Index + * the message by the same subject area name. + * - Mark functions in that subject area with the experimental decorator, passing in + * the subject area name as a parameter to markExperimental. e.g.: + * p5.registerDecorator( + * 'p5.prototype.buildComputeShader', + * markExperimental('webgpu', p5) + * ) + * + * If overriding a method on a class, additionally pass in a function to get to the + * p5 instance from the class, e.g.: + * + * p5.registerDecorator( + * 'p5.Shader.prototype.modify', + * markExperimental('p5.strands', p5, (shader) => shader._renderer?._pInst) + * ) + * + * ...or, if you need to conditionally warn about experimental functionality, you + * can directly call warnExperimental(p5, pInst, subjectArea) inside a function. + */ + +const experimentalMessages = { + webgpu: 'WEBGPU mode is experimental. Your feedback will help direct its development!', + 'p5.strands': 'p5.strands shaders are experimental. Your feedback will help shape its future!', +}; + +// Just in case it's not possible to get access to the p5 instance from something, +// we still don't want to make logs super noisy from repeated warnings, so we'll +// fall back on this global cache. It means if you create a second p5 instance, it +// wouldn't log again, but this is only here to handle edge case classes disconnected +// from the p5 instance anyway. +const globalWarningTarget = {}; + +export function warnExperimental(p5, pInst, subjectArea) { + const target = pInst || globalWarningTarget; + if (!p5.disableFriendlyErrors && !target.warnedExperimental?.[subjectArea]) { + target.warnedExperimental = target.warnedExperimental || {}; + target.warnedExperimental[subjectArea] = true; + + FES.log`${experimentalMessages[subjectArea]} For more info, see https://p5js.org/contribute/${subjectArea}/`(); + } +} + +export function markExperimental(subjectArea, p5, getPInst = (targetObj) => targetObj) { + return function (target) { + return function (...args) { + warnExperimental(p5, getPInst(this), subjectArea); + return target.apply(this, args); + } + }; +} diff --git a/src/core/p5.Renderer3D.js b/src/core/p5.Renderer3D.js index a84aa56679..7e6ef31cd0 100644 --- a/src/core/p5.Renderer3D.js +++ b/src/core/p5.Renderer3D.js @@ -24,6 +24,7 @@ import { Image } from '../image/p5.Image'; import { Texture } from '../webgl/p5.Texture'; import { makeFilterShader } from '../core/filterShaders'; import { getStrokeDefs } from '../webgl/enums'; +import { markExperimental } from '../core/experimental'; const { STROKE_CAP_ENUM, STROKE_JOIN_ENUM } = getStrokeDefs(() => ''); @@ -2356,6 +2357,7 @@ function renderer3D(p5, fn) { } return this._renderer.createStorage(dataOrCount); }; + p5.registerDecorator('p5.prototype.createStorage', markExperimental('webgpu', p5)); /** * Returns the default shader used for compute operations. @@ -2551,6 +2553,7 @@ function renderer3D(p5, fn) { } return this.baseComputeShader().modify(cb, context, { hook: 'iteration' }); }; + p5.registerDecorator('p5.prototype.buildComputeShader', markExperimental('webgpu', p5)); /** * Dispatches a compute shader to run on the GPU. diff --git a/src/webgl/material.js b/src/webgl/material.js index 894785c8c6..594ddecfb3 100644 --- a/src/webgl/material.js +++ b/src/webgl/material.js @@ -9,6 +9,7 @@ import { Renderer3D } from '../core/p5.Renderer3D'; import { Shader } from './p5.Shader'; import { request } from '../io/files'; import { Color } from '../color/p5.Color'; +import { markExperimental } from '../core/experimental'; async function urlToStrandsCallback(url) { const src = await fetch(url).then(res => res.text()); @@ -746,6 +747,7 @@ function material(p5, fn) { fn.buildFilterShader = function (callback, scope) { return this.baseFilterShader().modify(callback, scope); }; + p5.registerDecorator('p5.prototype.buildFilterShader', markExperimental('p5.strands', p5)); /** * Creates a p5.Shader object to be used with the @@ -1574,6 +1576,7 @@ function material(p5, fn) { fn.buildMaterialShader = function (cb, scope) { return this.baseMaterialShader().modify(cb, scope); }; + p5.registerDecorator('p5.prototype.buildMaterialShader', markExperimental('p5.strands', p5)); /** * Loads a new shader from a file that can change how fills are drawn. Pass the resulting @@ -1792,6 +1795,7 @@ function material(p5, fn) { fn.buildNormalShader = function (cb, scope) { return this.baseNormalShader().modify(cb, scope); }; + p5.registerDecorator('p5.prototype.buildNormalShader', markExperimental('p5.strands', p5)); /** * Loads a new shader from a file that can change how fills are drawn, based on the material used @@ -1956,6 +1960,7 @@ function material(p5, fn) { fn.buildColorShader = function (cb, scope) { return this.baseColorShader().modify(cb, scope); }; + p5.registerDecorator('p5.prototype.buildColorShader', markExperimental('p5.strands', p5)); /** * Loads a new shader from a file that can change how fills are drawn, based on the material used @@ -2213,6 +2218,7 @@ function material(p5, fn) { fn.buildStrokeShader = function (cb, scope) { return this.baseStrokeShader().modify(cb, scope); }; + p5.registerDecorator('p5.prototype.buildStrokeShader', markExperimental('p5.strands', p5)); /** * Loads a new shader from a file that can change how strokes are drawn. Pass the resulting diff --git a/src/webgpu/p5.RendererWebGPU.js b/src/webgpu/p5.RendererWebGPU.js index 072ae2f34c..a17ce69449 100644 --- a/src/webgpu/p5.RendererWebGPU.js +++ b/src/webgpu/p5.RendererWebGPU.js @@ -29,6 +29,8 @@ import { } from './shaders/imageLight'; import { baseComputeShader } from './shaders/compute'; +import { warnExperimental } from '../core/experimental'; + const FRAME_STATE = { PENDING: 0, UNPROMOTED: 1, @@ -391,6 +393,8 @@ function rendererWebGPU(p5, fn) { constructor(pInst, w, h, isMainCanvas, elt) { super(pInst, w, h, isMainCanvas, elt); + warnExperimental(p5, pInst, 'webgpu'); + // Used to group draws into one big render pass this.activeRenderPass = null; this.activeRenderPassEncoder = null; diff --git a/test/js/mocks.js b/test/js/mocks.js index 2a9150961d..db6fd86229 100644 --- a/test/js/mocks.js +++ b/test/js/mocks.js @@ -25,6 +25,7 @@ Object.assign(mockP5, { _validateParameters: vi.fn(), _friendlyFileLoadError: vi.fn(), _friendlyError: vi.fn(), + registerDecorator: vi.fn(), Renderer: { states: rendererStates } diff --git a/test/unit/webgl/p5.RendererGL.js b/test/unit/webgl/p5.RendererGL.js index 6d50708c7b..bcd04e97e7 100644 --- a/test/unit/webgl/p5.RendererGL.js +++ b/test/unit/webgl/p5.RendererGL.js @@ -2,6 +2,7 @@ import { suite, vi } from 'vitest'; import p5 from '../../../src/app.js'; import '../../js/chai_helpers'; const toArray = typedArray => Array.from(typedArray); +import { FES } from '../../../src/friendly_errors/fes'; suite('p5.RendererGL', function () { var myp5; @@ -89,6 +90,56 @@ suite('p5.RendererGL', function () { }); suite('p5.strands', function () { + suite('experimental usage warning', function () { + let logSpy; + beforeEach(function() { + logSpy = vi.spyOn(FES, 'log'); + myp5.createCanvas(5, 5, myp5.WEBGL); + }); + + afterEach(function() { + logSpy.mockRestore(); + }); + + test('shader creation logs a warning', function() { + const shader = myp5.buildMaterialShader(() => {}); + expect(logSpy).toHaveBeenCalled(); + expect(logSpy.mock.calls.length).toEqual(1); + }); + + test('warning logs only once with multiple shaders', function() { + const shader = myp5.buildMaterialShader(() => {}); + const shader2 = myp5.buildMaterialShader(() => {}); + expect(logSpy).toHaveBeenCalled(); + expect(logSpy.mock.calls.length).toEqual(1); + }); + + test('warning logs only once with multiple strands calls', function() { + const shader = myp5.buildMaterialShader(() => {}); + const shader2 = myp5.buildFilterShader(() => {}); + expect(logSpy).toHaveBeenCalled(); + expect(logSpy.mock.calls.length).toEqual(1); + }); + + suite('with FES disabled', function() { + let prevDisableFriendlyErrors; + + beforeEach(function() { + prevDisableFriendlyErrors = p5.disableFriendlyErrors; + p5.disableFriendlyErrors = true; + }); + + afterEach(function() { + p5.disableFriendlyErrors = prevDisableFriendlyErrors; + }); + + test('no warnings are logged', function() { + const shader = myp5.buildMaterialShader(() => {}); + expect(logSpy).not.toHaveBeenCalled(); + }); + }); + }); + test('a uniform whose name matches a hook parameter name does not break', function () { myp5.createCanvas(10, 10, myp5.WEBGL); myp5.pixelDensity(1);