Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

GLTFLoader plugin system, first round #19144

Merged
merged 8 commits into from
Jun 11, 2020

Conversation

takahirox
Copy link
Collaborator

@takahirox takahirox commented Apr 15, 2020

From #18484.

This PR adds hook points to GLTFLoader for the extensibility and rewrite clearcoat extension with the new system as the first extension handler. Let's make follow up PRs to move the other existing extension handlers to the new system one by one and add other necessary hook points for them.

API and example

// For overriding core `.loadMaterial`
class FooMaterialExtensionPlugin {
  constructor (parser) {
    this.parser = parser;
    // Plugin must have an unique name.
    // If plugin is an extension handler, the name should be extension name.
    this.name = 'EXT_foo';
  }

  loadMaterial(materialIndex) {
    const parser = this.parser;
    const json = parser.json;
    const materialDef = json.materials[materialIndex];
    if (!materialDef.extensions || !materialDef.extensions[this.name]) {
      return null;
    }
    const extensionDef = materialDef.extensions[this.name];
    const material = new THREE.FooMaterial();
    material.foo = extensionDef.foo;
    return Promise.resolve(material);
  }
}

// For adding some properties to core spec material
class BarMaterialExtensionPlugin {
  constructor (parser) {
    this.parser = parser;
    this.name = 'EXT_bar';
  }

  getMaterialType() {
    return THREE.BarMaterial;
  }

  extendMaterialParams(materialIndex, materialParams) {
    const parser = this.parser;
    const json = parser.json;
    const materialDef = json.materials[materialIndex];
    if (!materialDef.extensions || !materialDef.extensions[this.name]) {
      return Promise.resolve();
    }
    const extensionDef = materialDef.extensions[this.name];
    materialParams.bar = extensionDef.bar;
    return Promise.resolve();
  }
}

const loader = new GLTFLoader();
loader.register(parser => new FooMaterialExtensionPlugin(plugin));
loader.register(parser => new BarMaterialExtensionPlugin(plugin));
// loader.unregister(loader.pluginCallbacks[0]); // if you want to unregister a registered plugin
loader.load( ... );

Hook points this PR adds

  • loadMaterial
  • extendMaterialParams
  • getMaterialType
  • loadBufferView for @zeux's MESHOPT extension
  • loadMesh for @feiss's text mesh extension

@WestLangley
Copy link
Collaborator

Supporting chaining, also, would be nice for consistency.

new GLTFLoader()
    .register( FooMaterialExtensionPlugin )
    .register( BarMaterialExtensionPlugin )
    .load( ... );

@takahirox
Copy link
Collaborator Author

Good catch. I had forgotten to do that in this PR tho I did in the previous PR. Updated.

@robertlong
Copy link
Contributor

This is great! I don't have anything to add, other than I'd love to have a loadNode API, but I can wait if that would hold this up.

@munrocket
Copy link
Contributor

All is ok with CI here, freezing was fixed #19147
Changes not even covered with examples yet.

@mrdoob mrdoob requested a review from donmccurdy April 20, 2020 11:38
@mrdoob mrdoob added this to the rXXX milestone Apr 20, 2020
@zeux
Copy link
Contributor

zeux commented Apr 23, 2020

Thanks for working on this! A couple notes:

  1. It would be nice to be able to somehow configure an extension object. For example, my extension requires a decoder module that in three.js is usually (I think?) specified externally. See setDRACOLoader for example. Right now register() creates the plugin instance so I'm not sure how to do this.

  2. This code needs to be adjusted to recognize registered extensions and suppress the warning:

if ( extensionsRequired.indexOf( extensionName ) >= 0 ) {

    console.warn( 'THREE.GLTFLoader: Unknown extension "' + extensionName + '".' );

}

Other than these two issues I was able to get my extension running without changing GLTFLoader.js 🎉

@zeux
Copy link
Contributor

zeux commented Apr 23, 2020

For pt1, the way Babylon.JS works is it permits this:

loader.register((parser) => new MESHOPT_compression(parser, MeshoptDecoder));

(which doesn't work with this PR but probably could be made to work?)

@zeux
Copy link
Contributor

zeux commented Apr 23, 2020

... oh, the magic of JavaScript. The arrow function doesn't work but this does work:

loader.register(function (parser) { return new MESHOPT_compression(parser, MeshoptDecoder); });

So the only issue is the warning; suppressing this might be slightly awkward though with the current interface because the plugins must be created after the parser, but to create the parser we need the extension list. Maybe this can work with a second pass like this (after setPlugins call):

			parser.setPlugins( this.plugins );

			if ( json.extensionsRequired ) {

				for ( var i = 0; i < json.extensionsRequired.length; ++ i ) {

					var extensionName = json.extensionsRequired[ i ];

					if ( !parser.extensions[ extensionName ] ) {

						console.warn( 'THREE.GLTFLoader: Unknown extension "' + extensionName + '".' );

					}
				}
			}

			parser.parse( onLoad, onError );

zeux added a commit to zeux/meshoptimizer that referenced this pull request Apr 23, 2020
This is a GLTFLoader plugin for THREE.js that works with
mrdoob/three.js#19144.

Once that PR goes through we will be able to stop maintaining a custom
GLTFLoader build (modulo Basis/KTX2 changes that are still a bit in
flux...).
@takahirox
Copy link
Collaborator Author

@zeux Good catch! Yes, I think we should be able to pass parameter to plugin. I'll update.

@takahirox
Copy link
Collaborator Author

@zeux

  1. It would be nice to be able to somehow configure an extension object. For example, my extension requires a decoder module that in three.js is usually (I think?) specified externally. See setDRACOLoader for example. Right now register() creates the plugin instance so I'm not sure how to do this.

I updated to resolve 1. so far.

loader.register(parser => new FooPlugin(parser));

the magic of JavaScript. The arrow function doesn't work

Arrow function seems to work here. What type of error do you see?

@zeux
Copy link
Contributor

zeux commented May 6, 2020

@takahirox That comment was referring to old version of the code with ‘new’; with callbacks I believe arrow functions should work just fine.

@takahirox
Copy link
Collaborator Author

OK, thanks. And also I updated to resolve 2.

@zeux
Copy link
Contributor

zeux commented May 8, 2020

Thanks! Confirmed that the new code fixes all issues for my use case. Looks like all that's left is for @donmccurdy to review this?

@@ -2160,6 +2270,18 @@ THREE.GLTFLoader = ( function () {

}

materialType = this._invokeOne( function ( ext ) {

return ext.getMaterialType && ext.getMaterialType();
Copy link
Contributor

@robertlong robertlong May 9, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getMaterialType() should take an argument materialDef or materialIndex so that you can optionally use it to determine if a material applies to just this material definition.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed – I'd vote for materialIndex for consistency with load methods.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good to me. Added materialIndex, thanks.

Copy link
Collaborator

@donmccurdy donmccurdy left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A couple small comments but I think this is close! Thank you @takahirox!

@@ -110,10 +113,35 @@ THREE.GLTFLoader = ( function () {

},

register: function ( callback ) {

if ( ! this.pluginCallbacks.includes( callback ) ) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's avoid array methods missing from IE11 if we can, see #19293. It is simpler if the only polyfill GLTFLoader requires is the Promise API.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was missing IE11 doesn't support includes(). Replaced with indexOf(), thanks.


this.plugins = plugins;

};
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't intend for users to call setPlugins or setExtensions, right?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, I don't expect users to call them.

@@ -2160,6 +2270,18 @@ THREE.GLTFLoader = ( function () {

}

materialType = this._invokeOne( function ( ext ) {

return ext.getMaterialType && ext.getMaterialType();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed – I'd vote for materialIndex for consistency with load methods.

@donmccurdy
Copy link
Collaborator

Would it be OK to just have an extensions object, without the plugins object? And then as we convert the existing extensions to use the new system, they would be added to an extensionCallbacks list?

I realize that not all plugins will be related to glTF extensions, but I don't think it's necessary to try to separate the two concepts inside the loader, when they behave the same way.

@takahirox
Copy link
Collaborator Author

takahirox commented May 16, 2020

My plan is, plugins has the extension handlers using the new system while extensions has the extension handlers using the existing system, we will convert the existing extension handlers to the ones using the new system, and when we finish converting we will have only plugins (or extensions, either name is fine to me) object. plugins holds the new system handlers so it will have both glTF extension handlers and non-glTF-related handlers.

Let me confirm my understanding. Are you suggesting we would be better to have only one object even before we finish converting?

@donmccurdy
Copy link
Collaborator

Ok, I'm fine with having both during the transition. Once that is finished I think I would prefer to have only extensions.

Repository owner deleted a comment May 18, 2020
Repository owner deleted a comment May 18, 2020
@robertlong
Copy link
Contributor

Can we get a status update on this PR? I'm assuming we're waiting on tests, examples, and fixing merge conflicts? Is there more that needs to be figured out for the API?

@takahirox
Copy link
Collaborator Author

I think I have reflected all the comments to the code. Let me know if I have missed anything.

Copy link
Collaborator

@donmccurdy donmccurdy left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extensions are go! 🚀

@feiss
Copy link
Contributor

feiss commented Jun 10, 2020

Weeeeeeee!!

@mrdoob mrdoob merged commit 7d976f3 into mrdoob:dev Jun 11, 2020
@mrdoob
Copy link
Owner

mrdoob commented Jun 11, 2020

Thanks!

@takahirox takahirox deleted the GLTFLoaderPluginSystemNew branch June 11, 2020 05:20
@Mugen87 Mugen87 modified the milestones: rXXX, r118 Jun 22, 2020
@takahirox
Copy link
Collaborator Author

takahirox commented Jun 23, 2020

Thanks for merging.

By the way, I should have noted about loadNode() hookpoint.

I think we definitely should have loadNode() hookpoint but I want to discuss a bit about it separatedly from the first plugin PR so I didn't include it in this PR.

In this PR we use invokeOne() for loadXxx(). But it doesn't fit to Light extension. A node can have mesh, camera, and light extension at a time and all the objects should be created under the node. invokeOne() doesn't fit to it because invokeOne() stops to call the core loadXxx() if extension's loadXxx() creates an object. Then mesh and camera objects will not be created.

Probably we need workaround or something. Let's discuss more closely when we move Light extension to the new system.

@donmccurdy
Copy link
Collaborator

That makes sense, sounds like nodes should use invokeAll instead?

@takahirox
Copy link
Collaborator Author

takahirox commented Jun 23, 2020

invokeAll would work for light extension but not really sure for other possible node extensions yet. I'd like to hear more comments, for example I heard Hubs team wants node hookpoint and @feiss may need it for his text extension so I'd like to know if it satisfies their demands. (I know we should prioritize to support Khronos glTF extension but also it would be worth if we can support possible custom extensions without complicating.)

This thread is already closed, I'd like to continue the discussion in a new open thread. Probably it will be a good opportunity when moving light extension to the new system.

@donmccurdy
Copy link
Collaborator

We can use invokeAll for some hooks and invokeOne for others, that's the reason for having both. It seems pretty clear that loadNode must use invokeAll.

@takahirox
Copy link
Collaborator Author

takahirox commented Jun 23, 2020

I thought there could be a case where an extension in a node points to a mesh/camera and the core node points to another mesh/camera as fallback, meaning either one should be created depending on whether the extension is supported. But it seems to unlikely happen in possible custom extensions or future Khronos glTF extensions? If so, invokeAll should work fine.

"nodes": [
  "extensions": {
    "Foo_extension": {
      "mesh": 1
    }
  },
  "mesh": 0 // as fallback
]

@robertlong
Copy link
Contributor

robertlong commented Jun 23, 2020

Here's what I did for an internal Hubs hackathon: Hubs-Foundation/three.js@hubs/master...MozillaReality:hubs/feature/plugin-api

I needed to swap out all the Object3D types for one with an ECS mixin. We don't have this requirement anymore, but it was a useful thought exercise.

These were the hooks I implemented:

loadNode(index)
createPrimitive(meshIndex, primitiveIndex, geometry, material)
finalizeMesh(meshIndex, primitives)
createCamera(cameraIndex)

So even though I don't have concrete uses for these now, here are my thoughts on how you might use these methods:

loadNode lets you totally override the behavior of the loadNode method. I think most of the time you don't want to do this. loadNode does a lot. We'd need to break up the contents of that method into reusable parts.

createPrimitive lets you pick what Object3D class to use for a specific primitive, maybe I'd use this for creating an InstancedMesh or LOD?

finalizeMesh is where you take all of the primitives and add them to a Group, you could do anything that needed access to all of the primitive Object3Ds here.

createCamera is where the camera Object3D (PerspectiveCamera and OrthographicCamera) are created. The camera properties are still assigned in the body of loadCamera

I don't think any of these are final APIs, but maybe it's useful to see how I broke it apart. I think the text extension would be a good one to focus on for designing an API. The text extension isn't going to touch the mesh loading part of the GLTFLoader. It really just needs to alter what Object3D is returned from loadNode and I think that'd be a good starting point.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

9 participants