Load events for `Texture`

It is common to pass Texture instances around, but there is no API on Texture that allows code that receives a Texture to detect when the texture is loaded.

This leads to 3rd-party code needing hacks like polling loops to detect when a texture is loaded, for example here:

(cc @marcofugaro)

It would be nice if Texture, seeing as it already extends from EventDispatcher, would emit load event (when .image goes from null to a value that doesn’t need to load like a data array or ImageBitmap, otherwise for something like HTMLImageElement dispatches if the image is .complete otherwise waits for the image load event, etc) to tell people when the texture is loaded.

Real-world problem scenario

Not even using TextureLoader's load event in my code was good enough to re-draw my scene with a texture after three-projected-material detected the load event with its poll loop:

This caused an issue in my app: it renders the scene only when necessary (not an infinite render loop), and due to three-projected-material having an internal poll loop, I had no idea when to re-draw the scene after passing it a texture.

Despite me using on TextureLoader's load event (noting that three-projected-texture received a Texture and not a TextureLoader), I was re-drawing the scene too early. The setInterval within three-projected-material turned out to fire after my animation frame triggered from TextureLoader's load event, hence my re-draw would not include the texture because an internal state within three-projected-material was not toggled until after my frame fired.

Having a load event for Texture would make things easy: both my code and three-projected-material code can do what they both want in the load event, then my animation frame would come next and would not miss any state change that setInterval otherwise caused.


There are a bunch of APIs that accept textures, and they have no ability to run logic on load, which makes coordination difficult in certain cases.

Had all APIs been designed to instead accept TextureLoader instances, that would solve the problem, but it doesn’t seem like a good semantic solution: objects want textures, not loaders. Would it be strange if mesh.map accepted a TextureLoader? Perhaps it wouldn’t be although it could work.

I personally think that either Texture can fire load events depending on internal image type, or subclasses like ImageTexture, etc, can specifically fire events as needed.

Having a class with async data within it, but not having any clue as to when it is ready, is something we don’t see in any regular web APIs, and perhaps it is good to follow a queue from web APIs here.


An alternative is to recommend for authors making Texture-containing classes to always provide needsUpdate methods and let all end users always have to worry about when to update.

It seems cleaner to have the update event be in the shared common class (f.e. Texture), and allowing authors to opt into the events without their end users having to always be aware of calling a needsUpdate methods.

TextureLoader imo loads properly and informs you that the texture is there but that means nothing because three will only start the gpu upload once the material comes into view, which means you get FOUC. Gl.initTexture solves it pre-emptively drei/useTexture.tsx at 914d3c33bb3c05470f2b469cae677a08fe438dbf · pmndrs/drei · GitHub now the whole process can be awaited.

1 Like

Hm keep in mind that a texture itself does not ‘load’, an image loads. It’s extended by classes like CompressedTexture and DataTexture which have very different loading processes. It may (if anything) make more sense to observe the new Source class.

Personally I find it much easier to reason about await/async code than to pass around not-yet-loaded Texture instances:

// texture is ???
const texture = loader.load( 'path/to/image' );

// texture is ready!
const texture = await loader.loadAsync( 'path/to/image' );
3 Likes

Yes, but Three.js users don’t think about DOM images, they think about textures. They want to know when a texture loads regardless what type of image it has underneath.

This is indeed what TextureLoader already provides. I’m only pointing out the the TextureLoader API causes a disconnect: some users have TextureLoader, and most APIs accept only Texture (f.e. materials), therefore any APIs that accept Texture can not magically figure when the texture is loaded (something we can already do with TextureLoader) and require users to remember to set needsUpdate = true which is easy to forget and not always obvious when to use.

I’m simply pointing out that the current API is more difficult to use than it can be, but not actually proposing any new abilities, only a better way to organize features (in my opinion), because it would relieve end users of unnecessary responsibilities.

A TextureLoader can simply call texture.dispatchEvent('load') and problem solved, one line of code.

This would improve developer experience because then:

  1. authors do not need to think about always having to add needsUpdate calls for APIs that contain textures (but not loaders)
  2. end users don’t have to always remember to call needsUpdate.

Win win.

Perhaps, although it doesn’t matter: we just need to know when the texture is done loading regardless of how internally handles loading.

Maybe you missed my point. Some APIs receive textures, not loaders. How do you make the following work?

Library author:

class FunMaterial extends MeshPhysicalMaterial {
  #funTexture

  get funTexture() { return this.#funTexture }
  set funTexture(tex) {
    this.#funTexture = tex
    this.#handleTexture()
  }

  async #handleTexture() {
    if (!this.#funTexture.image) await /* what ? */
    this.uniforms.textureLoaded = true
  }
}

End user:

const mat = new FunMaterial()
// ...got a `texture` from somewhere already...
mat.funTexture = texture

The standard way that Three.js solves this problem is it has a needsUpdate setter for all materials, so the end user has to remember to call that, and I’m saying that this makes the API more difficult, more error prone.

Instead, if we add load events to Texture, one library author can handle the load event for all users, rather than all (many)_ end users having to remember signal the load event.

Yeah, that too, or something.

@donmccurdy, @Mugen87 rejected the Texture events PR, which you agreed with.

The loadAsync idea you gave does not support progress events. How do you propose to handle this case (which is still TODO for ImageLoader specifically) with promises?

Promises and async/await are nice, but they’re not always the best for every case, especially when for APIs that have complete, intermediate progress events, and errors.

We can obviously handle errors with try/catch using async/await but we’d then need to either keep using events for progress indication, or switch to for-await-of loops.

Here are two examples:

const tex = await loader.loadAsync('foo.png', function onProgress() {...})

or

for await (const [progress, texture] of loader.loadAsync('foo.png')) {
  if (!texture) {
    // show progress
  } else {
    // texture is loaded
  }
}

Please kindly take note that none of the above (both your proposal, or the existing TextureLoader callbacks) solve the issue I have outlined above regarding three-projected-material, and which I have also described here with examples, unless three-projected-material is modified to not wait for texture load and for the end user to call an API separately when a texture is loaded, or unless three-projected-material is updated to use TextureLoader directly or other modification that does not align with existing patterns in built-in Three.js materials.

Lastly, note that the small change in

is less maintenance than adding a new loadAsync API (and requires changes to built-in materials if we are to align 3rd-party libs with built-in patterns).

Also note that for await of loops are dangerous: users who are not aware will inadvertently kill parallezation of their apps if they don’t take care:

// this loop must finish before the next one can run, which may take a while
for await (const thing of items) {
 // ...
}

// this loop doesn't run until the previous finishes, wasting idle CPU (newbs will make this mistake *all* the time).
for await (const thing of otherItems) {
 // ...
}

To use promises properly, and include progress events, users will have to be aware that in order to parallelize loading, they will need to write code like this:

async function iterateThings(asyncIterable, fn) {
  for await (const thing of asyncIterable) fn(thing)
}

await Promise.all([
  iterateThings(loader.loadAsync('foo.png'), ([progress, texture]) => {...}),
  iterateThings(loader.loadAsync('bar.png'), ([progress, texture]) => {...}),
])

And now the syntax is not so great anymore, and in fact is just like events using callbacks, yet perhaps uglier.

This is very easy to overlook, and I’ve seen library documentations show examples while not parallelizing things, leading beginners to write horribly un-parallelized async/await code, which is not good.

Events, by default, are always parallelized (and event callbacks can themselves be async to further parallelize).

async/await is great, but it is not the best pattern for absolutely everything.

1 Like

The other option is to make loadAsync without progress notifications, but that’s a loss of features.

Texture is a base class. If we add things to it that don’t make sense for its subclasses, that’s a problem. Even ignoring CompressedTexture and DataTexture, a normal Texture could have an ImageBitmap as its source data — in which case a ‘load’ event is not meaningful.

If your API must know a texture has fully loaded, you can check that:

const img = texture.source.data;

if ( ! img.complete ) {

  img.addEventListener( 'load', () => { ... } );

}

It’s fine if you’d prefer callbacks instead of async/await, but TextureLoader does not support onProgress in either case.

1 Like

That doesn’t work (did you read the OP?) because texture.source.data can be null and there is no way to wait for it apart from polling.

Furthermore, TextureLoader.load() currently returns textures with source.data being null and does not set the image until later.

Should TextureLoader be modified so it returns a texture with the image already set, so we can observe the image?

Should TextureLoader.load() always return a subclass like ImageTexture that has source.data being initially an Image? Seems like a good idea.

An alternative is, what if we add isComplete to Texture, then things like DataTexture simply get to that state immediately, while textures that actually load would emit a load event later? For example:

if (!tex.isComplete) {
  tex.addEventListener('load', ...)
}

Code would then be agnostic, assuming that something like a DataTexture has source.data set up front (but that is not currently a guarantee).

It is impossible to know when any Texture is loaded; a Texture with source.data being null is not “loaded” (even if it is a DataTexture).


isComplete could be:

class DataTexture extends Texture {
  ...
  get isComplete () { return !!this.source.data }
  constructor (...) {
    ...
    const self = this
    this.source = {
      set data(v) {...; this.dispatchEvent({type: 'load'})},
      ...
    }
    ...
  }
  ...
}
class ImageTexture extends Texture {
  ...
  get isComplete () { return !!this.source.data?.complete }
  // ... dispatchEvents based on Image load ...
  ...
}

This would always work.

Sorry, maybe I was too verbose and you all simply didn’t have the time to understand the issue.

TLDR so far:

  • tex.source.data can be null, and there is no way currently, apart from polling, to detect when it is set,
  • The suggestion to use tex.source.data.addEventListener simply doesn’t work if tex.source.data is null.

If three-projected-material were my own library, I’d deliberately throw an error when given an un-initialized Texture if the library required that. It is reasonable to ask users to await an initialized texture before using it. I don’t love that TextureLoader returns empty textures synchronously, but I also don’t feel strongly enough to push for changing that.

I’d have fewer problems with adding something like texture.isComplete but it doesn’t seem to fully solve the problem on its own right, you still need to poll? Or perhaps a Promise, this avoids some of the problems with dispatching events:

texture.complete // Promise<void>

Imo texture loader is a helper for small projects
doesn’t need to fit all the cases, it’s perfect for the “I want quick results” ones.

For anything requiring a loading screen and assets juggling, classic DOM image loading is there.
and easily chainable with some new THREE.texture function. Perfect assets management doesn’t exist, they have to be adapted for each projects.

1 Like

Yep, we still have to poll if there’s no event or promise, in which case we can just poll for source.data.

How is it better than events? What problems are avoided?

For one, when should an event be emitted when THREE.DataTexture is initialized in the constructor? If we emit in the constructor, there won’t be any subscribers yet, if we use setTimeout(..., 0) that seems arbitrary. The Promise is just available on-demand.

We would do it the same as DOM APIs: if an <img> is already loaded, the complete is true, hence no need to listen.

if (img.complete) handleMyImage()
else img.addEventListener('load', handleMyImage, {once: true})

Same thing with DataTexture: if source.data is initially provided, then isComplete will be true. And same thing if a hypothetical ImageTexture were to receive an already loaded Image:

// `texture` comes from anywhere, loaded or not, but `isComplete` and a `load` event makes that irrelevant:

if (texture.isComplete) handleMyTexture()
else texture.addEventListener('load', handleMyTexture)

Promises make both library and user code more complicated for this specific use. Let me explain why.

“Events” are easier for repeated events. Library code:

// any time the event happens, signal to the user:
this.emit(event)

User code:

// just subscribe, and done:
texture.addEventListener('foo', handleEveryLoadEvent)

// ...optionally stop listening later:
texture.removeEventListener('foo', handleEveryLoadEvent) // simple

^ That’s how DOM APIs work.

Now, with promises, library code has to continually re-place the promise in order for it to be useful repeatedly:

#loadPromise = null // private class fields
#resolveLoad = null

get loadPromise() {
  if (!this.#loadPromise) this.#loadPromise = new Promise((resolve) => this.#resolveLoad = resolve)
  return this.#loadPromise
}

// any time the event happens, signal to the user:
if (this.#loadPromise) this.#resolveLoad()
this.#loadPromise = null
this.#resolveLoad = null

It may at first seem simple to subscribe to all events:

while (true) {
  await texture.#loadPromise
  handleEveryLoadEvent()
}

Unsubscribing becomes much more cumbersome for the end user:

let listen = true

while (listen) {
  await texture.#loadPromise

  // ...use your imagination here to figure how to stop this loop
  // when you need to, considering that it may be paused on the
  // previous line before any code here will ever run...

  handleEveryLoadEvent()
}

You can probably abstract these promises away, but then you’ll end up with a different (worse) way to implement an event pattern.

I explained this once before in another thread. I promise you (:smiley:), I’ve been using Promises since before any runtimes had them, and I can strongly recommend anyone not to use them for the pattern of repeated happenings (events). They are better for one-off sorts of things, or managing many async things in parallel or in sequence, but when it comes to repeated events, they’re not optimal.

Sure, if this happened repeatedly a Promise would be the wrong choice … but I think we can assume load is a one-time event?

It is not a one-time event, for example, if the texture author does this:

texture.image = image

and the new image needs to load.

And if we assign a new TypedArray to a DataTexture, is there a ‘load’ event, even though we didn’t fire one in its constructor the first time? Do we aggregate these events for a CubeTexture or fire six times? What if only one face of the cube changes? Or maybe only THREE.Texture instances backed by a single DOM element will fire ‘load’ events?

For me this just seems like too much risk of inconsistency for a feature that is not really getting a lot of demand or affecting most users.

Obviously yes, just like setting an image to a pre-calculated data blob in the DOM.

One event based on all six internal textures. The user can reach in and listen to individual sides if desired.

On the contrary, being consistent with the web APIs that everyone knows (the good ones) is a good thing. It would work similar to this:

Sorry, we disagree on a lot of this. It does not seem like we are going to convince one another, and I have no more to add here.