Skip to content

p5.strands: fragment shader that's not filter-oriented? #9326

Open

Description

@davepagurek

Increasing access

I was talking to @golanlevin about what it would take to make strands fit in a teaching environment. Currently his course involves providing a fragment shader p5 template that students can fill in, with some examples of doing things like gradients, noise, warping, etc. These are all things that you can do in strands with buildFilterShader, but notably, none of those tasks are filters, so the filter shader API adds some extra conceptual confusion that is not present in vanilla shaders. It seems really feasible that we could make an even simpler version of filter shaders tailored for focused teaching of drawing stuff based on coordinates.

Most appropriate sub-area of p5.js?

  • Accessibility
  • Color
  • Core/Environment/Rendering
  • Data
  • DOM
  • Events
  • Image
  • IO
  • Math
  • Typography
  • Utilities
  • WebGL
  • Build process
  • Unit testing
  • Internationalization
  • Friendly errors
  • Other (specify if possible)

Feature enhancement details

Golan refers to these types of full-screen fragment shaders as "fields", both in terms of Rothko color fields and also the idea of vector fields and pixel color fields. So probably we want to remove all verbiage about filters and use something else, "fields" being a good candidate. So the API could be something like:

function setup() {
  createCanvas(400, 400, WEBGL)
  myShader = buildColorFieldShader(gradient)
}

function gradient() {
  field.begin()
  field.color = [field.texCoord, 0, 1] // For example
  field.end()
}

function draw() {
  colorField(myShader)
}

Under the hood this could be another base shader, similar to the existing filter shader, with a slightly simpler API (no canvasContent), and a function to run it, colorField(...), which is maybe the same as filter(...) but without the sampling of the underlying canvas.

Activity

  1. LalitNarayanYadav commented on Oct 24, 2026

    @LalitNarayanYadav
    Contributor

    This would be a great fit for teaching. A few things worth settling before implementation:

    Naming and hooks

    • buildColorFieldShader / colorField() / field.* fits Golan's vocabulary. I'd keep field.color and field.texCoord parallel to the filter hooks (filterColor.*) so the two APIs don't drift apart.

    Inputs exposed on field

    • Teaching examples (noise, warping, gradients) almost always need more than normalized texCoord. Worth deciding now whether to also expose pixel coordinates, canvas resolution, and time, so students don't have to declare their own uniforms.

    Rendering path

    • colorField() should draw a full-screen quad independent of camera and transforms.
    • Should it work from a 2D canvas the way filter() does? The example uses createCanvas(400, 400, WEBGL), so I wasn't sure which is intended.
    • Target: current canvas only, or also framebuffers / p5.Graphics? filter() already has an answer we can reuse.
    • Does a field overwrite the canvas, or composite over it (alpha/blend)?

    Implementation

    • Most of this could reuse the filter shader's base shader, hook registration and quad pipeline, minus canvasContent, with its own hook namespace.

    I'd be happy to take this on if nobody else is already. I can start by tracing how buildFilterShader and filter() are wired and sketch the minimal diff for a colorField path.

  2. Akshats-git commented on Oct 25, 2026

    @Akshats-git
    Contributor

    Hey @davepagurek , love this idea. I found a few things about how filter() works. It renders into a separate framebuffer, clears the main canvas and then draws that framebuffer back on top. That only exists because filters have to read the canvas they're writing to. A field doesn't, so colorField() could probably draw straight into the current target. It would also answer the overwrite vs. composite question since it would blend under the current blendMode and you could stack fields with alpha.

    The filter vertex shader also goes through the projection and model-view matrices which is why filter() has to reset the camera. A field shader could just output clip-space positions and ignore the camera entirely. Not sure what it should do about depth in a 3D scene though. On inputs, FilterInputs already has canvasSize, so pixel coordinates are just texCoord * canvasSize. Only time is missing from it.

    One thing I wasn't sure about: is field.color = ... meant to replace filterColor.set(...)? It looks closer to how worldInputs works. If so, I wonder if field as a global name would clash with student variables.

  3. LalitNarayanYadav commented on Oct 25, 2026

    @LalitNarayanYadav
    Contributor

    Thanks @Akshats-git, this matches what I found in the source. filter() only needs the framebuffer round trip and filterCamera reset because it reads the canvas it writes to, so a field can draw straight into the active target and respect blendMode.

    I'd like to take this on, @davepagurek. My plan:

    • New field base vert/frag outputting clip-space positions, with no canvasContent
    • baseColorFieldShader() alongside the existing baseFilterShader() in the WebGL, 2D (FilterRenderer2D) and WebGPU renderers
    • buildColorFieldShader mirroring buildFilterShader, and colorField() drawing a quad into the current target
    • A built-in time input, since FilterInputs doesn't have one

    Open questions for you: depth handling in 3D scenes, and whether to keep .set() rather than field.color = ... for consistency with filterColor. Also, a global named field might clash with student variables.

  4. bhabishnu commented on Oct 26, 2026

    @bhabishnu
    Contributor

    Building on @Akshats-git's point and @LalitNarayanYadav's clip-space approach, I tried a small throwaway version and hit two things that may need handling explicitly.

    A fullscreen field can still affect later geometry since normal draws test/write depth, and I saw some WebGL/WebGPU differences around clip-space z.

    Also, in WebGL the framebuffer Y-flip currently comes through the camera. Once that's bypassed, texCoord.y flipped between the canvas and a p5.Framebuffer, while WebGPU stayed consistent.

    millis(), frameCount, and deltaTime already update inside Strands too, so a separate field time input may not be needed.

  5. davepagurek commented on Oct 26, 2026

    @davepagurek
    ContributorAuthor

    @bhabishnu @Akshats-git I don't think we need to consider completely jumping out of the existing camera system; for maintainability, it seems easiest to use the same vertex shader as before, and make running the shader effectively translate to:

    push()
    clearDepth()
    resetMatrix()
    camera(defaultCamera) // I forget the name for this but I think we have a camera for filters already
    shader(theShader)
    plane(width, height)
    pop()

    This is approximately what filter() already does, but more complicated to capture the existing screen content + also work in 2D mode. So then we don't need any special handling of z, depth, perspective matrices, etc. This also would make it work on framebuffers and graphics without extra work, using the existing syntax for running stuff on those (for framebuffers, you would call it between the framebuffer's begin and end; for graphics, you would call it as a method on the graphics object, which we get for any core add-on that we call once on the p5 prototype and once on the graphics prototype.) Generally I think the approach should be: we have a thing that mostly works, but the focus here is on making a stripped-down API, so we don't need to introduce potential other optimizations and potential bug sources.

    From the above, we would get blending, which I agree makes some interesting usage opportunities and is worth keeping.

    Should it work from a 2D canvas the way filter() does? The example uses createCanvas(400, 400, WEBGL), so I wasn't sure which is intended.

    This is largely a vehicle for teaching GPU programming, which will need WebGL mode anyway, so I think it's OK to just make this for WebGL mode.

    millis(), frameCount, and deltaTime already update inside Strands too, so a separate field time input may not be needed.

    Originally the filter hook came with more properties because it was designed to be used as a GLSL function, without access to existing p5 globals. Today, though, the API has evolved, and you have access to width and height and frameCount, etc. (Do we support pixelDensity() in strands? That's one thing the current filter API currently has, indirectly, by exposing the texel size. But probably the way to add it here is to add support for pixelDensity() in strands.) So, I agree that we don't need any extra inputs other than the texture coordinate; the rest can come from the other p5 builtins.

    One thing I wasn't sure about: is field.color = ... meant to replace filterColor.set(...)? It looks closer to how worldInputs works. If so, I wonder if field as a global name would clash with student variables.

    Similar to the above, the set() syntax was kind of never originally planned: at first, every hook was a function, and you would return stuff. But then we switched hooks to primarily use begin/end, which means we can't return without affecting control flow, and so set was introduced in place of return, but also we made it so that hooks with multiple properties let you assign to them directly without having to set. Arguably assigning to a property feels more javascript-native, and we want to slightly move away from set? That also would have future implications for the filterColor hook if we decide we want to align on not using set generally, but we can decide that later I think; for now, the main question to answer is, which feels more natural coming from the rest of p5? I think I slightly lean away from set for that case, but I am not certain enough to just make a decision. I would love some feedback, especially from anyone who teaches with p5 currently.

  6. bhabishnu commented on Oct 26, 2026

    @bhabishnu
    Contributor

    Thanks @davepagurek , I tried the camera-based version and it does seem to fix the orientation/parity issues I was seeing.

    One thing I noticed: with clearDepth() only before the plane, the plane still writes depth and can affect geometry drawn afterwards. Would it make sense to clear again after the plane too, similar to what filter() already does?

    That seemed to give the expected result on both WebGL and WebGPU in my test.

  7. golanlevin commented on Oct 26, 2026

    @golanlevin

    Just chiming in to say that I'm very excited about this development! Thanks @davepagurek for championing my suggestion.

  8. davepagurek commented on Oct 26, 2026

    @davepagurek
    ContributorAuthor

    8. One thing I noticed: with clearDepth() only before the plane, the plane still writes depth and can affect geometry drawn afterwards. Would it make sense to clear again after the plane too, similar to what filter() already does?

    Good catch, I think we should!

  9. davepagurek commented on Oct 26, 2026

    @davepagurek
    ContributorAuthor

    @LalitNarayanYadav I'll assign this to you, let me know though if there's anything else that comes up that needs clarification!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions