`import from 'three/tsl'`, WARNING: Multiple instances of Three.js being imported

Shipping both three.webgl.js and three.module.js with duplicate Three.js code causes a duplicate-threejs hazard when any projects import both.

It is too easy for one library to import from 'three' and some other library to import from three/tsl or three/webgpu. One or more of the following warning may appear in console:

WARNING: Multiple instances of Three.js being imported.

When this happens, there are a couple ways to fix it:

  • vanilla browser ESM users can easily modify their importmap
  • people with a build, especially people new to web development, may have a harder time trying to figure how to alias their tooling to the correct paths.
  • some people may be oblivous, and their app accidentally works (later it may break)

The original philosophy of Three.js is to keep things as simple as possible. For example that’s why TypeScript is not part of core, because it adds complexity that the main maintainers don’t want.

Likewise, the current Rollup build and the Node-specific exports field in package.json add a barrier of complexity for people who don’t know web tooling, or for people who are not into Node.js but trying to consume code written with Node.js standards (which are not web standards).

My thought is that the duplicate problem can be eliminated at least in the build/ folder.

Here’s my suggestion:

  • make src/Three.WebGPU.js re-export stuff from ./Three.js instead of duplicating the exports
  • then build three.module.js the same as usual
  • then build three.webgpu.js with three.module.js as an external module (f.e. three.webgpu.js will have export * from 'three' or export * from './three.module.js', I think the last one is better for plain ESM, plus either one can be overriden in an importmap).

Downside (worst case): two files are downloaded for Three.js for vanilla JSM users instead of one, the second file now provides extra WebGPU stuff. Two files instead of one is not a bad compromise for the overall gain:

Upside: the duplicate Three.js problem will be eliminated, and the setup made overall simpler with less likely of tooling issues needing to be configured. Libraries can freely import from three, three/tsl, or three/webgpu without causing tooling conflicts or duplicate Three.js instances. With this simpler setup, existing projects could just add an import statement and move forward.

I believe this change will be non-breaking, and if anything it may fix some projects that may currently have accidental duplicate Three.js libs.

P.S. happy to try to make this change if approved to move forward with it.

I wrote this because in the real world I wanted to try out TSL in an existing Three.js project, and found it difficult to import the new TSL features. Unfortunately for me, this particular project has a build, so I cannot just tweak a standard importmap. The project’s tooling is Vinxi+Nitro+Vite, which I’m sure has options, but I’ll have to go fiddle with the tooling config to get it working.

The problem with package.json exports:

Here is the package.json exports field:

  "exports": {
    ".": {
      "import": "./build/three.module.js",
      "require": "./build/three.cjs"
    },
    "./examples/fonts/*": "./examples/fonts/*",
    "./examples/jsm/*": "./examples/jsm/*",
    "./addons": "./examples/jsm/Addons.js",
    "./addons/*": "./examples/jsm/*",
    "./src/*": "./src/*",
    "./webgpu": "./build/three.webgpu.js",
    "./tsl": "./build/three.webgpu.js"
  },

We can see two things here, besides the rest:

  • import {anything} from 'three' results in stuff that will come from ./build/three.module.js
  • import {anything} from 'three/tsl' or import {anything} from 'three/tsl' results in classes coming from ./build/three.webgpu.js, with many things that are duplicates of stuff in three.module.js.

Now, let’s consider an app that has this:

// suppose this is in library-a
import {MeshPhongMaterial} from 'three'
// app code:
import {something} from 'library-a'

Next, suppose the app author wants to import from three/tsl to try the new TSL language, or suppose the app author simply imports a 3rd party library (they do not know if it imports from three or three/tsl):

// suppose this is in library-b
import {MeshPhysicalNodeMaterial} from 'three/tsl'
// app code:
import {something} from 'library-a'
import {otherThing} from 'library-b'

Now, the user may have no idea, but due to the package.json exports of Three.js, their app bundle will now have duplicate Three.js libs (if they use a bundler). Maybe they can use smart tree shaking if they aware of it, or if the tooling even supports it. Or they might not even realize a problem exists.

Or the Three user might be importing into a runtime, not using a bundler. For example if they import directly into Node.js and use a Node.js WebGL or WebGPU backend for Node, then Node will by default import both versions of Three.js into one app.

Etc.

Example in Three.js repo:

  • WaterMesh.js in examples imports from three/tsl (library-a)
  • Lensflare.js from examples imports from three (library-b)

EDIT:

I noticed Three.WebGPU.js has commented out WebGLRenderer and related classes. I don’t know if there’s some reason for that. But in order to import everything, aliases for a build tool need to be roughly this (give or take the config format for your tool):

			alias: [
				{
					find: /^three$/,
					replacement: path.resolve('node_modules', 'three', 'src', 'Three.js'),
				},
				{
					find: /^three\/tsl$/,
					replacement: path.resolve('node_modules', 'three', 'src', 'Three.WebGPU.js'),
				},
				{
					find: /^three\/webgpu$/,
					replacement: path.resolve('node_modules', 'three', 'src', 'Three.WebGPU.js'),
				},
				{
					find: /^three\/src\/Three.js$/,
					replacement: path.resolve('node_modules', 'three', 'src', 'Three.js'),
				},
			],

What this does is it ensures that Three.js is imported from a single location on disk, avoiding any duplicates.

1 Like

Ah, I just noticed this move of nodes/ into src/ is very recent. Congrats @sunag. Exciting stuff!

I’m currently dealing with an error TypeError: undefined is not an object (evaluating 'string.replace') trying to replace a MeshPhysicalMaterial with a MeshPhysicalNodeMaterial, and I haven’t solved the duplicate Three.js issue with my setup yet.

I was at two warnings when I first added import ... from 'three/tsl':

then playing with aliases got me down to one (the above aliases actually didn’t work as good as I thought):

So I’m hoping getting rid of the last duplicate will solve the error. :crossed_fingers:

EDIT: I think that error is because node material → WebGLRenderer.

Ah, I managed to resolve duplicates with Vite config for Vinxi for Solid Start, but ironically the message is because both src/Three.js and src/Three.WebGPU.js contain

if (typeof window !== 'undefined') {
    if (window.__THREE__) {
        console.warn('WARNING: Multiple instances of Three.js being imported.');
    } else {
        window.__THREE__ = REVISION;
    }
}

So even without a duplicate, as I’m trying to import both, it shows the message.

I’m expecting mixing WebGL + WebGPU stuff obviously won’t work. I heard TSL supports GLSL? Maybe I need to enable that mode?

Ah ok, looks like we’re in a transitionary phase now:

Part of the discussion above mentioned the legacy node support for WebGLRenderer which has been removed with r164 in the meanwhile.

(from here)

So, that explains why WebGLRenderer and related classes are commented out in the Three.WebGPU.js file.

Hmmm, tricky situation.

I guess I’m just thinking out loud at this point. :smiley:

Thinking about this just a little more, keeping in mind that the non-node materials are usable with WebGPURenderer, it’ll be good to fix the import situation to avoid duplicate Three.js. This way someone not relying on onBeforeCompile and custom GLSL can still migrate more easily. Thoughts @sunag?

Site note, if using native importmaps in a browser (instead of a build tool), we can avoid duplicate Three.js libs by linking to src/ files instead of build/ files.

Instead of this:

<script type="impotmap">{
  "imports": {
    "three": "https://cdn.jsdelivr.net/npm/three@0.167.0/build/three.module.js",
    "three/tsl": "https://cdn.jsdelivr.net/npm/three@0.167.0/build/three.webgpu.js"
  }
}</script>

Do this:

<script type="impotmap">{
  "imports": {
    "three": "https://cdn.jsdelivr.net/npm/three@0.167.0/src/Three.js",
    "three/tsl": "https://cdn.jsdelivr.net/npm/three@0.167.0/src/Three.WebGPU.js"
  }
}</script>

Because both src/Three.js and src/Three.WebGPU.js import from other files in the src/ folder using relative paths, the JS module system will automatically resolve the same files, ensuring that duplicates of any modules are avoided (whereas the build/three.*.js files are bundled, and each one includes the a copy of the whole Three lib (with differences)).

The caveat with mapping to src/ instead of build/ is that more files will be downloaded by the browser (more individual JS modules, as opposed to two single (but duplicated) big bundles). This can be optimized though: when you’re ready to optimize (if needed, sometimes you don’t need to do this), you can use your own bundler to create a bundle based on your app entrypoint (which is importing from src/ to ensure that it’ll avoid duplicates, especially if a smart enough tree shaking system is not available to otherwise detect duplicate in build/ files).

In r168, you can avoid the “multiple instances” error like this.

Using a buildtool

-import * as THREE from 'three'
+import * as THREE from 'three/tsl'

...

-const renderer = new THREE.WebGLRenderer()
+const renderer = new THREE.WebGPURenderer()

Using importmaps

<script type="importmap">
    {
        "imports": {
-           "three": "/build/three.module.min.js",
+           "three": "/build/three.webgpu.js",
            "three/addons/": "/jsm/"
        }
    }
</script>

…

-const renderer = new THREE.WebGLRenderer()
+const renderer = new THREE.WebGPURenderer()

Example Three r168,

@seanwasere hey thanks. However for some libraries and frameworks that will support both WebGLRenderer and WebGPURenderer for some time, this won’t work. If they import from both build/three.module.js (for WebGLRenderer) and build/three.webgpu.js (for WebGPURenderer) they’ll end up with duplicates. They may want to provide an option (f.e. <lume-scene mode="webgl"> or <lume-scene mode="webgpu"> for Lume, or something similar for react-three-fiber). There are so many features already built on WebGL, so frameworks will want to make it easy to switch between the two, without the duplicate libs, so that users can easily try WebGPU or go back to WebGL, depending on which features work or are needed.

If it is up to the user to pick either build/three.webgpu.js or build/three.module.js, and not the framework choice, then the framework simply won’t work and cannot be written for either renderer.

Libs/frameworks may have to tell users to map or alias to src/Three.js and src/Three.WebGPU.js to avoid duplicates (downside of more separate HTTP requests with importmap, but at least it will be duplicate free), although currently this causes a false duplicate instance warning.

To alleviate this issue and make ecosystem migration easier, being able to import both renderers without the duplicate issue would be the key.

1 Like

@drcmda any thoughts on this for r3f yet?

Oh, another option is for a lib/framework to just import everything from /src directly, without worrying about the top-level module, f.e. import ... from 'three/src/renderers/webgpu/WebGPURenderer.js', etc, and telling their users to import from src/ files too. This works with plain ESM with the most minimal importmap:

           "three/": "/node_modules/three/",

An advantage of importing from src/ for HTTP/3 module servers is only the files directly imported can be sent to the client in a single request (similar to a bundle, but smaller payload if not everything from Three is imported).

A user can optimize their app separately using any bundler they want. The current issue we have is that Three lib pre-optimizes with its own build, but now we have the two bundles instead of one.

Downside of this is if the user installs some other Threejs lib that imports from one of the bundles, then the user in that case needs to make sure to update the importmap or build tool alias to point to src/Three.*.js.

For WebGPURenderer only use three.webgpu.js, never import both together. All classes from three.js including TSL with the exception of WebGLRenderer are included in three.webgpu.js.

Many classes have been modified like PMREMGenerator, so if you just import WebGPURenderer in three.module.js things will probably break at some point.

Thing is Lume needs to support WebGLRenderer too, for back compat, until WebGL stabilizes, so while I add WebGPU support, it will be optional, something like:

<lume-scene mode="webgl"></lume-scene>
<!-- or -->
<lume-scene mode="webgpu"></lume-scene>

For that reason Lume source needs to be able to import both. Would be more hassle to make two separate versions of Lume (like three.module and three.webgpu).

There’s only one WebGPURenderer though right? I can import either one from src/ directly (instead of bundles) to avoid duplicate Three.js code:

import WebGPURenderer from 'three/src/renderers/webgpu/WebGPURenderer.js'
import WebGLRenderer from 'three/src/renderers/WebGLRenderer.js'

EDIT: Ah wait, there’s two as of a few days ago: WebGPURenderer.js and WebGPURenderer.Nodes.js (brand new), which differ by StandardNodeLibrary and BasicNodeLibrary respectively but otherwise are the same. What’s that difference for?

Imo a bug that three has to resolve. You can use bundler aliases but a library shouldn’t export like that.

2 Likes

I also agree that the current exporting situation really does not play nice with standard bundler tools. I think this will be a pretty big bottleneck in adoption of TSL for more serious projects.

The only way I’ve been able to make the imports work in my project is by importing from the direct source files in my source:

import WebGPURenderer from 'three/src/renderers/webgpu/WebGPURenderer.js'

This is clearly suboptimal imo.

1 Like

Ongoing discussion of how to resolve this, agreed this is something we’ll need to fix in the library’s packaging for the WebGPU builds.

3 Likes