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.72k stars 3.34k forks source link

[p5.js 2.0 RFC Proposal]: Async/Await setup() #6767

Open limzykenneth opened 10 months ago

limzykenneth commented 10 months ago

Increasing access

Adopting async/await can help with sketch authors' adoption of modern JavaScript syntax and best practices, making their skills more generally transferrable. The overall syntax may be considered to be simpler than the need to use the preload() function as well.

Which types of changes would be made?

Most appropriate sub-area of p5.js?

What's the problem?

Taking directly from the current RFC

The async setup() function eliminates the need to have the preload() function. As such the data loading codebase will be refactored to remove preload() related code, while documentation around data loading should be updated accordingly.

What's the solution?

Data loading functions will all be updated to be fully async using promises (with async/await) while keeping the callback syntax if necessary. setup() will be awaited in the runtime and if the user define it as async functions, the promisified data loading functions can be awaited in them. With an async setup() function, the draw() loop will only start after the setup() function has resolved.

draw() can be defined as an async function as well, although this is not recommended because of the possible timing conflict it may have with requestAnimationFrame. (need more testing to confirm)


In terms of example code, in global mode, to load an image and display it:

let img;
async function setup(){
  createCanvas(400, 400);
  img = await loadImage("./Assets/cat.jpg");
  console.log(img);
}

function draw(){
  background(200);
  image(img, 300, 300, 100, 100);
}

In instance mode:

const sketch = function(p){
  let img;
  p.setup = async function(){
    p.createCanvas(400, 400);
    img = await p.loadImage("./Assets/cat.jpg");
  }

  p.draw = function(){
    p.background(200);
    p.image(img, 300, 300, 100, 100);
  }
};

new p5(sketch);

This will extend to all loading functions and can be used by addon libraries simply by making any asynchronous operation async.

For internal loading functions, the callback functions may be retained, eg. loadImage(imgSrc, successCallback, failureCallback), although a case can be made to not have this and just use the underlying promise instead, eg. loadimage(imgSrc).then(successCallback).catch(failureCallback).

A working example for this can be seen here.

Pros (updated based on community comments)

Cons (updated based on community comments)

Proposal status

Under review

nickmcintyre commented 10 months ago

@limzykenneth this looks good to me. Most people have written dozens of sketches by the time they load external media. Introducing async / await syntax shouldn't be a big leap for beginners by that point.

davepagurek commented 10 months ago

One other point to mention: if one is loading multiple files, the behaviour of current p5's preload is more equivalent to if the user awaits Promise.all(...) for all their assets. We probably still want that behaviour if we can, for faster load times. Do you think this is something we want to make users do manually?

An option we can consider that would introduce some magic under the hood is to internally keep track of all the promises created by load* functions, and only run setup after awaiting Promise.all(...) of all of those. Then users could still await things within preload if some assets have dependencies on other assets (e.g. load a text file, and then load an image for each line in the file) but by default still have everything load in parallel. The downside of something like that is that anything users load, they'd still want to assign to a variable so they can use it in draw, so this would re-introduce the issue where we have to return an object immediately, and then modify it on load, preventing us from loadJSONing both objects and arrays.

Another option is to add a helper or something with a slightly nicer API. Maybe:

let myJSON, myStrings;

async function preload() {
  // Like Promise.all but working on object values instead of array items
  { myJSON, myStrings } = loadAll({
    myJSON: loadJSON('something.json'),
    myStrings: loadStrings('something.txt')
  });
}

...but that could use some work since it's relying on destructuring, which is sort of advances javascript for newcomers. Hopefully other people have some other API ideas?

limzykenneth commented 10 months ago

@davepagurek Parallel loading is indeed a potential concern for this. I'm initially thinking just leaning into promises if the syntax don't feel too complicated.

let img1, img2, img3;
async function setup(){
  [img1, img2, img3] = await Promise.all([
    loadImage("cat1.jpg"),
    loadImage("cat2.jpg"),
    loadImage("cat3.jpg")
  ]);
}

There's also the potential issue of error handling which with async/await we'll need to use try...catch.

nickmcintyre commented 10 months ago

Thanks for pointing this out @davepagurek. It feels like we should hide this complexity if at all possible. Loading a few images quickly (in parallel) should be as straightforward as possible.

Unless teachers do a lot of hand waving, the current suggestions would force beginners to learn some mix of JavaScript objects/arrays, destructuring, async / await, Promise objects, and dot notation in order to draw a few cats.

davepagurek commented 10 months ago

I'm not sure that this is a good idea, but what if we do something like this:

let data
async function setup() {
  data = await preload({
    img1: loadImage('cat1.jpg'),
    img2: loadImage('cat2.jpg'),
    img3: loadImage('cat3.jpg'),
  })
}

function draw() {
  image(data.img1, 0, 0)
  image(data.img2, 100, 0)
  image(data.img3, 200, 0)
}

Here, each load* method still returns a promise, and the preload method is something like this, which loads all the values in parallel but then returns an object with the same keys mapped to the loaded values:

async function preload(data) {
  const result = {}
  await Promise.all(Object.keys(data).map(async (key) => {
    result[key] = await data[key]
  })
  return result
}

More advanced users could destructure this if they want, but otherwise, they can just leave it as an object. The benefit of using this thing that works like Promise.all but for object values is that you can refer to the result using more readable keys, like data.img1, rather than just by index. I'm not sure if that's better enough to be a solution though.

nickmcintyre commented 10 months ago

Neat, that's definitely getting simpler. Objects (or arrays) filled with function calls seem a little complex, though.

How about overloading the load* functions so that they use Promise.all() when an object is passed? Doing so would mean that users only get partial parallelism (per load* function). I believe the code would be easier to reason about, so maybe that's an OK tradeoff.

// Single image.
let img;

async function setup() {
  img = await loadImage("cat.jpg");
}

function draw() {
  image(img, 0, 0);
}
// Multiple images.
let data;

async function setup() {
  data = await loadImage({
    img1: "cat1.jpg",
    img2: "cat2.jpg",
    img3: "cat3.jpg",
  });
}

function draw() {
  image(data.img1, 0, 0)
  image(data.img2, 100, 0)
  image(data.img3, 200, 0)
}

Compared to the current preload() function, the only new concepts and syntax would be JavaScript objects {} and async / await.

Could we get full parallelism in a loadAll() function by inferring file types and calling the appropriate load* function?

// Image and JSON.
let data;

async function setup() {
  createCanvas(400, 400);

  data = await loadAll({
    img: "cat.jpg",
    pos: "position.json",
  });
}

function draw() {
  background(200);

  // Get current position.
  let pos = data.pos[frameCount];

  // Draw cat.
  image(data.img, pos.x, pos.y);
}
GregStanton commented 10 months ago

Hi all. There's a lot of creative discussion going on here! I have a different view of this, but I could be wrong. Basically, I think the native syntax is significantly less semantic than the current p5 syntax, and more complex for non-experts. But there might be benefits to this proposal that I don't fully understand yet.

On semantic code

With preload(), the user can guess the meaning without documentation: it loads things in advance. With the combination of setup(), async, and await, guessing the meaning is quite difficult. It's not too hard to write down a plausible train of thought that would lead a beginner to incorrectly guess the meaning.

In any case, they're likely to try to look up the meaning instead of spending a long time guessing, and this is equally fraught. As noted on MDN, "Asynchronous JavaScript is a fairly advanced topic." Tutorials tend to be long and assume multiple prerequisites beginners won't have. And it's not hard to find forums where people say they've read all the articles and videos but still can't understand it. Here's an example, so that we have some direct evidence.

Leaving the user to rely on these kinds of articles and tutorials seems problematic for p5, and explaining async/await in a reference page seems like it'd be quite difficult. It's also not clear where we'd do that. Since these are features of JavaScript itself, the natural place seems to be the Foundation section of the p5.js reference, but async/await is not a beginner-level concept like the other entries in that section. To me, that's a red flag. If a feature is not a basic aspect of JavaScript, and it's essential for making sketches, then p5.js generally provides a more friendly API on top of it. That's a big part of what sets p5.js apart.

On simple syntax

Syntactically, preload() also seems much simpler for non-experts, since it's a simple function call like the rest of the p5.js API, and it takes no parameters like most of the other functions in the Structure section of the reference.

With async/await, we preface function declarations with unfamiliar keywords, and we need to pass in an object literal. To an experienced JavaScript programmer, this use of object literals is a great simplification because we can basically simulate named parameters. But for a beginning p5.js user, an object literal as a function argument is likely to raise a lot of questions:

What's this syntax with curly braces inside of parentheses? Is that a special thing for load functions? Why can't I just pass in separate values like usual? I can't remember, was it a square brace or a curly brace? Was there a colon or a semi-colon between the two things? Between the other two things, was there a semicolon or a comma?"

Learning new syntax is beneficial for users, but as a rule, I think it's probably good to introduce new syntax when it becomes necessary, and no sooner. The current API with preload() seems to show these complications aren't necessary.

On modern syntax

I still need to study this more, but my understanding is that JavaScript originally used nested callbacks to accomplish the programming tasks we're discussing here. But, nesting lots of functions inside other functions resulted in code that was hard to look at (so-called "callback hell"). So, in ECMAScript 2015 (ES6), promises were introduced, and these supported chaining with dot notation. That was more readable but still involved connecting a lot of instructions together. Later, in ECMAScript 2017, async/await was introduced, which made it possible to replace chaining with a sequence of separate instructions (just like the most basic JavaScript syntax). That was a great simplification, so people were excited about it.

Based on all this, using callbacks for these sorts of tasks may be viewed as outdated. But old isn't necessarily bad. The original problem that needed to be solved was that deeply nested functions aren't very readable. That doesn't seem to be a problem with the current API. Am I wrong? I haven't worked with this area of p5 much.

Benefits?

I wouldn't think that removing preload(), by itself, is enough of a justification. But if the other benefits of this proposal are significant enough, then the points I made above may not matter, even if they're correct. Could anyone give examples of the benefits that better compatibility with other promise-based APIs would bring? Are there other benefits, such as extra flexibility in cases that are likely to be encountered? Are there specific benefits for add-on libraries over the current API?

nickmcintyre commented 10 months ago

I'm definitely in the preload() is simpler camp. But I wonder if we can find the simplest possible approach to async/await for discussion's sake. If we can reduce the complexity enough for beginners, then I'm all for pulling the curtain back a bit.

freeCodeCamp's explanation of the await keyword seems like a reasonable starting point:

The await keyword basically makes JavaScript wait until the Promise object is resolved or rejected.

would become something like

The await keyword basically makes p5.js wait until the data loads or an error is detected.

For parallelism, we could simplify a bit further by working with arguments and Promise.all() behind the scenes. This version would return an array:

// Single image.
let img;

async function setup() {
  img = await loadImage("cat.jpg");
}

function draw() {
  image(img, 0, 0);
}
// Multiple images.
let img1;
let img2;
let img3;

async function setup() {
  img1 = await loadImage("cat1.jpg");
  img2 = await loadImage("cat2.jpg");
  img3 = await loadImage("cat3.jpg");
}

function draw() {
  image(img1, 0, 0);
  image(img2, 100, 0);
  image(img3, 200, 0);
}
// Multiple images in parallel.
let img1;
let img2;
let img3;

async function setup() {
  let data = await loadImage("cat1.jpg", "cat2.jpg", "cat3.jpg");
  img1 = data[0];
  img2 = data[1];
  img3 = data[2];
}

function draw() {
  image(img1, 0, 0);
  image(img2, 100, 0);
  image(img3, 200, 0);
}
// Image and JSON in parallel.
let img;
let pos;

async function setup() {
  let data = await loadAll("cat.jpg", "position.json");
  img = data[0];
  pos = data[1];
}

function draw() {
  image(img, pos.x, pos.y);
}
davepagurek commented 10 months ago

One benefit of the async syntax is that it means there are less states where you could be looking at a variable in an incomplete form. e.g.:

let data
function preload() {
  data = loadJSON('data.json')
  console.log(data) // just logs {}
} 

function setup() {
  console.log(data) // logs the actual data now
}

vs:

let data
async function setup() {
  data = await loadJSON('data.json')
  console.log(data) // logs the data
} 

Our load* functions have to return something right away and then update the object when it has data. await acts like a barrier, so we go from a state of having a promise to having real data, without that odd in-between of having something but where you have to know it's not actually complete yet.

Another side effect of having to immediately return an object is that it has to stay an object. This means when you loadJSON, we return an object immediately, but if the data turns out to be an array, we're forced to convert it to an object. There are a number of issues opened in the past about this, e.g. https://github.com/processing/p5.js/issues/2154, but we're technically unable to solve it with the current preload setup. With async functions, we can return a promise that resolves to either an object or an array. (Another solution to this might be to just implement a loadJSONArray function.)

Not sure if these alone are enough to tilt the scales, but I wanted to mention them to paint a complete picture!

GregStanton commented 10 months ago

Thanks @nickmcintyre! You make a great point. I totally agree that it's best to seek out the simplest version of asyc/await for the sake of discussion. And wow. Your latest version is looking quite good.

The way you handle the second case, where await is used multiple times, might actually be simpler than using p5's load* functions with callbacks. I'm glad you realized I was wrong about the need to pass in an object literal; I didn't think carefully enough about that.

As far as documentation goes, maybe we could have one short explanation of async/await that just explains the basic idea in relation to this particular use, in a combined async/await entry under the Foundation section of the reference. And the reference pages for each of the load* functions could link to that. This way, we wouldn't need to maintain explanations in the reference pages for each load* function.

Also, @davepagurek, the first benefit that you mentioned seems like it could be pretty significant, just by itself. I need to look into the second one more to understand the options there, but it definitely seems relevant too.

I'm hoping to come back to this when I have more time so that I can understand all the nuances better, but I might actually be in favor of the proposal now. I just need to think more.

GregStanton commented 10 months ago

The original proposal mentions keeping the callback syntax if necessary. Does anyone know yet if that'd still be necessary if @nickmcintyre's latest idea is adopted?

nickmcintyre commented 10 months ago

@limzykenneth mentioned the following "unusual use case" which I've edited lightly:

function setup() {
  loadImage("cat.jpg", successCallback);

  createCanvas(400, 400);
  circle(200, 200, 100);
}

function successCallback(img) {
  image(img, 0, 0);
}

Here's what that could look like with promises:

// Use .then() to draw a cat when it arrives.

function setup() {
  let data = loadImage("cat.jpg");
  data.then(drawCat);

  createCanvas(400, 400);
  circle(200, 200, 100);
}

function drawCat(img) {
  image(img, 0, 0);
}
// Use .catch() to handle a loading error.

function setup() {
  let data = loadImage("cat.jpg");
  data.then(drawCat).catch(logError);

  createCanvas(400, 400);
  circle(200, 200, 100);
}

function drawCat(img) {
  image(img, 0, 0);
}

function logError(error) {
  console.error("🙀", error);
}

Thoughts?

hiddenenigma commented 7 months ago

I'm not entirely sure about this since it does seem to complicate things for beginners. I don't know enough about how things are done behind the scenes so let me know if this is possible, but if we were to truly make this straightforward, can we detect when someone is loading data in the setup() function? If so, then under the hood we handle the async/await instead of making the user do this. This would also be closer to the Processing syntax but I'm not sure if cross compatibility is something we consider when making changes.

davepagurek commented 7 months ago

We could "listen" for load* methods in setup similar to what we currently do in preload, but in JavaScript we're unable to actually wait for the load to finish without either using await or some kind of callback system (which is what separating preload from setup actually is under the hood: setup is kind of like a callback that gets called when all the preloads are done.) So I think in a world where we don't ask users to use await, we'd need to keep preload as a separate step in order to have a spot in the code where you can do whatever setup you need while knowing that you have all the data loaded.

We could potentially support both by having load* methods all have a property with a promise that you can await. So this would mean that you could continue using preload like you currently do, or alternatively, something like this in setup:

let img
async function setup() {
  img = await loadImage('assets/cat.jpg').promise
  createCanvas(500, 500)
}

We can be flexible about how you access the promise, either via a property, a method, wrapping it in another function, or making another type of function that returns it directly, like loadImagePromise(...). The new downside this introduces is that this could lead to potentially confusing behaviour if you accidentally await a normal load* object and not its promise.

mattdesl commented 6 months ago

I am 100% in the camp of async/await, lack of support has been a paint point for me when teaching p5.js and trying to get students out of the p5-only box. #2154 is also something I have run into often, where loadJSON does not work as a student would expect (arrays and numbers are valid JSON, unless you're using p5!). And, when using p5.js with other libraries (like Tone.js), you will often need to use async/await, and it is good to have support for this in setup and/or preload.

I feel this is quite clean and not that much more difficult than the current preload approach. I think students can understand that "loading stuff from the internet" has to have special keywords around async/await.

let size2d;

async function preload () {
  size2d = await load('./canvas-size-2d.json'); // JSON array works finally
  console.log(size2d); // student can even log it immediately
}

function setup () {
  createCanvas(size2d[0], size2d[1]);
}

I think it would be good if the below was functionally equivalent to the above, so that an explicit preload is really just for convenience & legacy sake.

async function setup () {
  const size2d = await load('./canvas-size-2d.json');
  createCanvas(size2d[0], size2d[1]);
}

I have a module that might be relevant here for reference: load-asset. It uses a single interface rather than explicit functions, which is a departure from p5's current approach, and I think a case could be made for either approach. One benefit of the single interface, is that it just becomes "the thing that has the special async/await syntax" and you can combine several different asset types into the same parallel sequence.

async function setup () {
  // load all assets in sequence, uses Promise.all
  const assets = await load.all([ 'a.png', 'b.json', 'c.csv' ]);

  // same, but using a named object pattern 
  const { normal, diffuse } = await load.all({
    normal: 'foo/normal.png',
    diffuse: 'foo/diffuse.png'
  });
}

I think this is pretty easy for students to understand: if a file ends with .json it will be loaded as a JSON object, if it ends with .png it will be loaded as an image, .csv as a table, if it ends with .bin it will be loaded as binary. If an advanced student wants to load a JSON as a binary, an interface like { url, type } or load.Types.Binary('./test.json') could be fine too.

Something to consider is errors. Try/catch is not something I usually teach in beginner p5 classes. And, most of the time, I don't even bother handling errors: if I can't load my data or images, the whole sketch is pretty useless. I think errors/failures can be handled similarly to how they are now with preload:

Currently, if I try to loadImage('nonexistent.png') inside preload, my sketch will fail to load and just show "Loading..." forever. This is OK, perhaps easy to miss if a student doesn't have the console open wide enough to see an error message. A similar thing could be done here: students do not need to worry about try/catch, and if any error occurs during preload or setup, the sketch just doesn't load. Perhaps as a better DX/accessibility, the "Loading..." message could be replaced with the failure message pointing to the asset that did not load. However, with this approach, advanced students who want to handle try/catch can still do so per asset, as there is no magic happening under the hood, and it's just regular promises.

multimonos commented 5 months ago

If the general opinion is that async/await is an advanced topic for beginners we could consider extension.

If the desired behaviour is blocking await, then perhaps this pattern is a good first test case.

const sketch = p => {

    mixAsyncHooks( p ) // wraps preload, setup, draw in a blocking way using existing core fns where required

    p.preloadAsync = async () => {
        await new Promise( resolve => setTimeout( resolve, 5000 ) )
        console.log( 'preloaded A' )
        await new Promise( resolve => setTimeout( resolve, 1000 ) )
        console.log( 'preloaded B' )
    }

    p.setupAsync = async () => {
        await new Promise( resolve => setTimeout( resolve, 1500 ) )
        console.log( 'setup A' )
        await new Promise( resolve => setTimeout( resolve, 500 ) )
        console.log( 'setup B' )
    }

    p.drawAsync = () => {
        console.log( 'drawn' )
        p.noLoop()
    }

}
mvicky2592 commented 5 months ago

I like @mattdesl's suggestion of one function load could be used to load any kind of files.

There's no need for a separate load.all function though. load could just accept multiple files like this and use Promise.all to load them asynchronously.

async function setup () {
  const images = await load('a.png', 'b.png', 'c.png');
}

Use of objects and object destructuring is too complex for beginners. preload will still be the easiest and most efficient way to load multiple files and store them in named variables.

I don't understand how could p5.js tell if the user is awaiting loadImage for example vs if the user expects an image to be returned. Users can lazy load images in setup and expect loadImage to return an image object. That behavior should be preserved.

davepagurek commented 5 months ago

I don't understand how could p5.js tell if the user is awaiting loadImage for example vs if the user expects an image to be returned.

Not sure if this is a good idea or not, but in theory we could change the behaviour depending on whether or not it's called from preload or setup by setting some flag before running preload and setting it to something else after.

Supporting both means compromising on some things though:

All of those options are feasible but have different tradeoffs.

So I think the first decision we have to make is, do we need to support both the old async-free preload and also async in setup? If so, then the next decision would probably be what compromises to make to support both.

It's sounding to me like we'll need to support both to stay beginner friendly, so my opinion would be to try to support both, and pick our compromises to try to make it as easy as possible to jump from the beginner-friendly API to the more JS-centric API.

mvicky2592 commented 5 months ago

@davepagurek @nickmcintyre @limzykenneth The existing preload system is perfectly designed on the user end for beginners. I'm strongly against dropping it for the sake of "progress". If setup will be optionally async why would preload need to be eliminated? Can't both systems co-exist?

let normal;
let diffuse;

function preload() {
  normal = loadImage('foo/normal.png');
  diffuse = loadImage('foo/diffuse.png');
}

If the only option to load in parallel in p5.js v2.0 was doing it the following way, that'd cause a huge decrease in accessibility. Should users really need to understand how async/await, object syntax, and object destructuring just to load two images in parallel? I don't think @mattdesl was suggesting that at all.

let normal;
let diffuse;

async function setup() {
    { normal, diffuse } = await load({
      normal: 'foo/normal.png',
      diffuse: 'foo/diffuse.png'
    });
}

I suggest that load be the new method for async loading. Changing loadImage and all the other current loading methods would be a huge backwards compatibility break. Even though loadImage could be made to return an image in preload and a promise in setup, that would break lazy loading in setup, which enables sketches to just start without requiring that all the images be loaded.

nickmcintyre commented 5 months ago

@mvicky2592 thanks for tagging me. In general, I'm in favor of considering just about any idea as long as long as the discussion is constructive.

@mattdesl and I suggested similar syntax for async/await. Here's a snippet from my previous comment:

// Image and JSON in parallel.
let img;
let pos;

async function setup() {
  let data = await loadAll("cat.jpg", "position.json");
  img = data[0];
  pos = data[1];
}

function draw() {
  image(img, pos.x, pos.y);
}

Swap loadAll() for load() and we're almost of like mind. I likeload() because it's cleaner, but I think we should avoid passing an array or object. Using arguments in the implementation would make the call signature simpler. @mvicky2592 made a similar point.

I agree with @mattdesl on the following point:

I feel this is quite clean and not that much more difficult than the current preload approach. I think students can understand that "loading stuff from the internet" has to have special keywords around async/await.

Here's how I suggested we go about it:

freeCodeCamp's explanation of the await keyword seems like a reasonable starting point: "The await keyword basically makes JavaScript wait until the Promise object is resolved or rejected." would become something like "The await keyword basically makes p5.js wait until the data loads or an error is detected."

async/await syntax isn't beginner-unfriendly; it's just different. I'd be happy to draft a quick tutorial and/or reference page to demonstrate how it could be explained to beginners.

That being said, mixing async paradigms would be confusing to beginners. Borrowing from the Zen of Python, "There should be one-- and preferably only one --obvious way to do it." If async/await is simple enough, which I believe it is, then we should remove preload().

@davepagurek it'd be nice if load("cat.jpg", "position.json") inferred types. For advanced use cases, what do you think about overloading load() so that it can accept objects with paths and file types, as in load({ path: "https://example.com/bucket", type: "jpg" })?

davepagurek commented 5 months ago

@davepagurek it'd be nice if load("cat.jpg", "position.json") inferred types. What do you think about overloading load() so that it can accept objects with paths and file types, as in load({ path: "https://example.com/bucket", type: "jpg" })?

We'd only need it if we want to use the load(...) syntax in preload as well as async setup, but yeah that combination of features and compromises sounds reasonable to me!

mvicky2592 commented 5 months ago

@nickmcintyre Even doing that with an array is still not as simple as directly assigning vars with preload.

Why would we add complexity to the p5 user experience if the end result (loading images in parallel) is the same? This suggestion to remove preload goes against p5's mission to make coding more accessible anyway you slice it.

The existing preload system and an async setup don't need to be mixed. They can stay separate and that gives educators the ability to teach the way they prefer. I really agree with @GregStanton that "old isn't necessarily bad" and indeed the strength of preload is its simplicity.

There are use cases where the new way would be preferable certainly, but the opposite is also true.

nickmcintyre commented 5 months ago

We'd only need it if we want to use the load(...) syntax in preload as well as async setup,

@davepagurek oh oops! Gotcha.

Why would we add complexity to the p5 user experience if the end result (loading images in parallel) is the same?

@mvicky2592 to paraphrase @limzykenneth's proposal, async/await is idiomatic JavaScript and would be helpful to people who want to integrate their p5.js sketches with the rest of the JavaScript ecosystem. If the approach can be made simple enough, then it's definitely worth consideration.

I'd probably introduce load() in the following sequence, focusing on images:

let img;

// You can load files such as images by calling the load() function.
// Doing so requires changing the setup() function a little.
//
// The "async" and "await" keywords let p5.js know that you're
// loading data, so it should wait until the data loads. "async"
//  and "await" are always used together.

// "async" goes before the "function" keyword.
async function setup() {
 // "await" goes before the call to load().
  img = await load("cat.jpg");
}

function draw() {
  image(img, 0, 0);
}
let img1;
let img2;

// You can load two images by calling load() twice.
async function setup() {
  img1 = await load("cat1.jpg");
  img2 = await load("cat2.jpg");
}

function draw() {
  image(img1, 0, 0);
  image(img2, 50, 0);
}
let img1;
let img2;
let img3;
let img4;

// If you need to load many images, you can load them at the same
// time by passing all of their paths to load(). When you do, load()
// will return an array.
async function setup() {
  let data = await load("cat1.jpg", "cat2.jpg", "cat3.jpg", "cat4.jpg");
  img1 = data[0];
  img2 = data[1];
  img3 = data[2];
  img4 = data[3];
}

function draw() {
  image(img1, 0, 0);
  image(img2, 50, 0);
  image(img3 0, 50);
  image(img4, 50, 50);
}

The last use case seems to be the sticking point. For this optimization to work, teachers would need to introduce arrays, at least in passing.

@davepagurek @limzykenneth would browser caching make files load pretty quickly if a sketch is run more than once? If so, then parallelism could probably wait until it's really needed; beginners could just call load() multiple times without a huge impact on their learning experience.

I'm currently teaching creative coding with p5.js to a group of high school students who are beginners. Happy to test any/all of this out next week and report back if that'd be helpful.

mvicky2592 commented 5 months ago

@nickmcintyre Teaching students to load assets sequentially for simplicity's sake is, by your own admission, an anti-pattern they'll need to unlearn later.

I concede that for the typical amount of images beginners are loading (probably less than 20) the real-world difference is likely minimal, although It'd be great if someone could do test on that once load is implemented.

With preload beginners don't have to know about arrays or worry about sequential vs parallel loading for their assets to load efficiently. Clearly this was done purposefully by p5's creators. I think it'd be good to get their advice on whether or not to replace preload with async setup which is a radical change and would void backwards compatibility for a majority of sketches.

davepagurek commented 5 months ago

So I think the current tradeoff being discussed is where we want to compromise between these two stances, one stated concisely by @nickmcintyre:

async/await is idiomatic JavaScript and would be helpful to people who want to integrate their p5.js sketches with the rest of the JavaScript ecosystem.

mixing async paradigms would be confusing to beginners. Borrowing from the Zen of Python, "There should be one-- and preferably only one --obvious way to do it." If async/await is simple enough, which I believe it is, then we should remove preload().

...and this by @mvicky2592:

With preload beginners don't have to know about arrays or worry about sequential vs parallel loading for their assets to load efficiently. Clearly this was done purposefully by p5's creators. I think it'd be good to get their advice on whether or not to replace preload with async setup which is a radical change and would void backwards compatibility for a majority of sketches.

some options I see:

Let me know if anyone has any other ideas that are worth considering too!

also:

@davepagurek @limzykenneth would browser caching make files load pretty quickly if a sketch is run more than once?

I think so, so the concern would be less for when students are iterating, and more for when showing the sketch to other people.

mvicky2592 commented 5 months ago

@davepagurek Yeah my vote is having preload and async setup co-exist, there's no technical reason that couldn't be the case as far as I can tell.

Also I assumed since load will always return a promise it'd only be used in async setup.

the concern would be less for when students are iterating, and more for when showing the sketch to other people.

and yes I agree

davepagurek commented 5 months ago

Also I assumed since load will always return a promise it'd only be used in async setup.

Right, there's a world where we can change that behaviour and make it not return a promise if it's in preload, but that comes with its own compromises (now you have to know that it behaves differently in different circumstances, or not think too hard about it and accept its magic.)

mvicky2592 commented 5 months ago

@davepagurek Ah yeah I forgot about that idea. For functions like loadImage that'd break backwards compatibility with lazy loading in setup as I previously said but with a new function load its definitely possible but still maybe too confusing and not really necessary if all the old loading functions would still exist.

nickmcintyre commented 5 months ago

@mvicky2592 just a friendly vibe check, I'd appreciate it if you took a bit more care with phrasing when you disagree with people. Comments like "Not supporting it shouldn't even be considered as an option in my opinion." are needlessly dismissive. Suggesting that people must not understand p5.js' design goals when they propose changes you disagree with is condescending; it made me shut down and want to leave the discussion.

nickmcintyre commented 5 months ago

@nickmcintyre Teaching students to load assets sequentially for simplicity's sake is, by your own admission, an anti-pattern they'll need to unlearn later.

Teaching approaches differ, but I often use ant-patterns to motivate new concepts. For example, magic numbers on the way to variables, copy/pasting on the way to loops, or defining several similar/related variables on the way to data structures.

I've taught parallelism in Scratch to many elementary and middle school students after a couple of lessons. It's pretty painless for beginners to pick up on the idea that multiple things can happen at once. Parallelism is an important concept, and I think it'd actually be beneficial for beginners to learn it explicitly with p5.js.

A couple of questions come to mind: