processing / p5.js

p5.js is a client-side JS platform that empowers artists, designers, students, and anyone to learn to code and express themselves creatively on the web. It is based on the core principles of Processing. http://twitter.com/p5xjs —
http://p5js.org/
GNU Lesser General Public License v2.1
21.7k stars 3.34k forks source link

Namespaced mode #7332

Open araid opened 1 month ago

araid commented 1 month ago

Increasing access

unsure

Most appropriate sub-area of p5.js?

Feature request details

The current global and instance modes are not ideal for integrating p5 into larger web applications:

It would be great for 2.0 to add a third instantiation mode that's more in line with how THREE, Babylon or other libraries work.

In this mode, p5 functions would be neatly contained in a single global object, getting us closer to the modular approach that's already underway. Lingdong explains it very well in his q5xjs library.

So instead of doing this:

class Rectangle {
  constructor(sketch) {
    this.sketch = sketch;
  }
  draw() {
    this.sketch.fill(255);
    this.sketch.rect(50, 50, 50, 50);
  }
}

let sketch = function(p) {
  let rectangle;
  p.setup = function() {
    p.createCanvas(100,100);
    rectangle = new Rectangle(p);
  };
  p.draw = function(){
    p.background(0);
    rectangle.draw();
  }
};

let myp5 = new p5(sketch);

we would do this:

class Rectangle {
  draw() {
    q5.fill(255);
    q5.rect(50, 50, 50, 50);
  }
}

let q5 = new p5();
let rectangle;

q5.setup = function(){
  q5.createCanvas(100,100);
  rectangle = new Rectangle();
}

q5.draw = function(){
  q5.background(0);
  rectangle.draw();
}

which seems like a very small gain but can lead to much more readable code as the application grows.

davepagurek commented 1 month ago

I think p5 instance mode already lets you do that pretty much: https://editor.p5js.org/davepagurek/sketches/RLPK_J382

The one difference is that it seems to require that you pass a function into the p5 constructor currently, even if it's empty. So maybe the only thing needed to get what you're describing is the ability to use the p5 constructor with no arguments, with the expectation that it will be assigned manually afterwards?

araid commented 1 month ago

Thanks @davepagurek, this is great. I didn't realize you could define setup and draw outside of new p5().

Would this still work in a modular architecture though? If we imagine this code split into 2 modulesmain.js and rectangle.js, the q5 or sketch variables wouldn't be visible by the rectangle class.

This is of course assuming p5 v2.0 will support ESM. Please correct me if I'm wrong.

davepagurek commented 1 month ago

Would this still work in a modular architecture though? If we imagine this code split into 2 modulesmain.js and rectangle.js, the q5 or sketch variables wouldn't be visible by the rectangle class.

That's true, but also is a problem in q5.js too -- you still would have to give the namespace/instance to the class somehow if it isn't able to grab it from the local scope.

A kind of hack you could use is to declare an empty local variable in the Rectangle file, and export a function to supply the real thing:

let p5 = undefined

class Rectangle {
  draw() {
    p5.fill(255);
    p5.rect(50, 50, 50, 50);
  }
}

Rectangle.setup = function(instance) {
  p5 = instance;
};

and then in your main sketch:

import { Rectangle } from './Rectangle.mjs';

let q5 = new p5(() => {});
Rectangle.setup(q5); // Pass in the instance here

let rectangle;

q5.setup = function(){
  q5.createCanvas(100,100);
  rectangle = new Rectangle();
}

q5.draw = function(){
  q5.background(0);
  rectangle.draw();
}

or, to make one global namespace, have a file like this:

export const q5 = new p5(() => {});

and then import that in all your other files:

import { q5 } from './global.mjs'
class Rectangle {
  draw() {
    q5.fill(255);
    q5.rect(50, 50, 50, 50);
  }
}
import { q5 } from './global.mjs'
import { Rectangle } from './Rectangle.mjs';

let rectangle;

q5.setup = function(){
  q5.createCanvas(100,100);
  rectangle = new Rectangle();
}

q5.draw = function(){
  q5.background(0);
  rectangle.draw();
}
limzykenneth commented 1 month ago

@araid 2.0 will support ESM and due to the nature of ESM, sketches have to be written in instance mode. The way we are testing and iterating in 2.0 development is using an ESM sketch.

I would recommend the Rectangle class to have a reference to the p5 sketch instance from its constructor instead of having it in the class's environment somehow as it increase the flexibility of the Rectangle class being able to be used in multiple instances of p5, and stick closer to the composition pattern of objects.

araid commented 4 weeks ago

Thanks, this all makes sense. @davepagurek the solution of creating the sketch in a global file seems the closest to what I'm proposing.

@limzykenneth imho there's a difference in terms of readability and being able to reuse modules, between the current "instance mode" and a potential "namespaced" or "library" mode:

class Rectangle { constructor(p5) { // currently this.p5 = p5; } ...



I still believe supporting this architecture would be valuable to people looking to integrate p5.js in larger apps along other libraries, but I'm not familiar enough with the codebase to estimate the complexity of building it. I'd love for the feature to be considered for 2.0 but feel free to close the issue if it doesn't sound feasible. 

Thanks again!
davepagurek commented 4 weeks ago

@limzykenneth I think we don't actually need anything too heavy to support this. I think to fully allow sketches to be declared outside of a sketch callback in instance mode, we'd need to:

limzykenneth commented 4 weeks ago

@davepagurek

not assume that we're in global mode if you don't pass sketch in to new p5() (maybe have a different signature that we use internally to start global mode?)

The existing mechanics is that to defer global mode initialization p5.instance is set to not null then when ready new p5() is called to initiate global mode. If we were to change default behavior of calling new p5(), this mechanic will need to change as well.

if everything's loaded in the head tag, it's already sufficient since p5's _start() is only called on the window load event, giving user code a chance to attach setup and draw to the instance. But if it's initialized when the page has already loaded, to make sure setup happens after user code has had a chance to run, I think we'd just need to defer start by doing Promise.resolve().then(() => this._start()) to stick it at the end of the execution queue.

This I feel have a bit too much assumption in that it only works as part of a script tag include, not with ESM. It's also a bit hard to know how much to defer initialization if for example the user code attaches to setup and draw aynchronously.

@araid For Three.js they work quite differently from p5.js in that Three.js does not provide a runtime and so they don't have an initialization, meaning it doesn't matter the order in which things are defined for the most part, also they use a very different mostly OOP based API whereas p5.js are almost entirely procedural.

The syntax @davepagurek suggested will work if you wish to expose the p5 instance to the environment instead of having it be owned by the object, it is just my opinion that attaching the instance as a class member is the better approach in terms of reusability and flexibility.

import p5 from 'p5'; // ideally imports the constructor, not the instance so I'm not sure how that will work though.

limzykenneth commented 4 weeks ago

@davepagurek Thinking out loud a bit, I think what we can do/look into instead if we want to enable something like namespaced mode, we can look into the initialization step in init.js. init.js is exclusively run for global mode, if no window attached setup() or draw() exists, it already will not start global mode initialization.

In ESM instance mode, init.js will basically never run so we can try to split things based on that. Let me have a think first.

limzykenneth commented 4 weeks ago

We make the following assumptions for anyone wishing to use namespace mode:

  1. Using script tag include which includes init.js (more restrictive, ESM don't need this assumption and can still apply below)
  2. Not defining neither global setup() nor draw() so global mode won't be triggerred
  3. Manually creating a p5 instance with const p = new p5()
  4. Attaching setup(), draw(), and whatever else to p

A potential way going back to @davepagurek's suggestion of Promise.resolve().then(() => this._start()), we instead indefinitely defer it until this.setup or this.draw is defined:

const repeatUntilInit = () => {
   if(this.setup) {
    this._start();
  }else{
    setTimeout(repeatUntilInit);
  }
}

repeatUntilInit();

Not sure if this breaks anything though.

davepagurek commented 4 weeks ago

This I feel have a bit too much assumption in that it only works as part of a script tag include, not with ESM. It's also a bit hard to know how much to defer initialization if for example the user code attaches to setup and draw aynchronously.

I guess we already don't support asynchronously attaching setup/draw in the currently recommended initialization, right?

const sketch = new p5(async (p) => {
  await new Promise((res) => setTimeout(res, 5000))
  p.setup = () => p.createCanvas(200, 200)
  p.draw = () => p.background('red')
})

Given that, I think it's not too much of a stretch to tell users that if they want to add setup/draw externally, it would also need to be done synchronously after construction, but I guess that means just doing new p5() in one file and attaching setup and draw in another file wouldn't be supported because it wouldn't reliably be synchronous.

A potential way going back to @davepagurek's suggestion of Promise.resolve().then(() => this._start()), we instead indefinitely defer it until this.setup or this.draw is defined

That looks like it could work, or also assigning setters on the setup and draw properties of the sketch so that when they're modified, we can immediately start initialization?

class p5 {
  // ...

  set setup(cb) {
    this._setup = cb
    this.start()
  }
}

...or one other option is to just expose a .start() that you have to manually call if you don't immediately pass a sketch function into the constructor.

limzykenneth commented 4 weeks ago

I like the setter option but it may have problem where setup is defined and the runtime start but draw is not yet defined. We can try to add a setTimeout defer to that if setup and draw are in the same event loop cycle it will be available when the runtime starts, it doesn't solve the problem of asynchronously attached setup and draw or any other user defined functions but it probably is an ok tradeoff, given realistically there probably isn't a possible start time if we make that assumption.

Calling a .start() manually is pretty close to the current API of calling the constructor after defining the sketch so I feel it's not adding too much value here.

I can look into the code and implementation after I'm done with the refactoring but in any case the priority is to make sure global mode, instance mode, and possibly defer global mode we currently have work the same way otherwise we could be breaking a huge amount of existing tutorials out there.

davepagurek commented 4 weeks ago

Makes sense! Yeah I consider this mostly a nice-to-have if we've got time