Advice needed on making working with materials more sane and efficient

I’m currently using a heavily modified PhongMaterial to render my objects. The modifications are done as patches using onBeforeCompile, and things are starting to become unwieldy and ugly. I have 500 lines of patch code just to make the material use array textures. You can’t even read and understand the shader code as a whole; it’s a huge unmaintainable mess of patchwork, impossible to reason about.

I’ve sensed from the start that this patch approach would be madness, but I did it anyway against my better judgement.

Then I want to adjust the material’s uniforms at runtime, but it looks like that’s not a simple thing to do? Like material.shader.uniforms.shininess.value = value is not easily possible, you have to use a hack involving onBeforeRender. And if I want to use different initial material parameters (uniforms) for every object, the same identical shader code is compiled again, for every object, because the value of a uniform is different. This makes no sense to me at all. Uniforms exist to prevent exactly this.

So here’s what I want to do: copy the PhongMaterial, make it a ShaderMaterial, using the GLSL shader code from the original material, so I can modify the shader code directly instead of having this patch madness that will break anyway sooner or later.
Use one shared instance of the material for every object, and adjust the uniforms on a per-object basis instead of per-material. I don’t need another instance of an identical shader just because one object uses a slightly different value for the shininess uniform.

Before I do this, I would appreciate advice from more experienced devs: is this a sensible thing to do? Am I missing something? Is the above rant accurate or am I doing it wrong? How would you solve the issues?

Use TSL!

If it weren’t for TSL, I would’ve recommended building factory functions or js wrappers that generate GLSL strings based on input values. But TSL already handles that for you. Its entire purpose is to transpile your JS code into GLSL or WGSL.

Plus, writing shaders in JS gives you access to all the language features, modular import/export, proper scoping, constants, destructuring, and the entire JS ecosystem, including NPM packages and tooling.

Take the uniform management problem you mentioned, for example. With TSL, you can define a uniform once and reuse it across multiple materials or compute shaders.

export const myUniform = uniform(value); // Import and use it anywhere!

The same principle of reusability applies to almost everything in TSL, variables, attributes, or functions…

Is TSL worth learning?

In your case, it’s a definite yes. Otherwise, you’ll end up writing custom JavaScript wrappers to solve the same problems TSL already solved.

Where to start:

3 Likes

I’ve heard of TSL but I was under the impression it’s for WebGPU only. Seems like that is not accurate and it can generate GLSL?! Is it tested and mature?

Well, I’m gonna have a look and port my extensive changes over to see for myself. That’s gonna be quite a bit of work.

Edit: took a quick look at MeshPhongNodeMaterial and it just wraps MeshPhongMaterial. So I’m gonna have to reimplement the material if I want to change it?

1 Like

To a certain extent, it supports both WebGL and WebGPU. Some advanced WebGPU features are not yet fully supported. The WebGPURenderer will automatically detect if WebGPU is available and fall back to WebGL if it’s not. You can also force it to use WebGL explicitly by setting the forceWebGL flag:

WebGPURenderer({ forceWebGL: true })

The idea that more languages can be added and transpiled into any shader language is just :exploding_head:.

From what I’ve seen, yes! Most official examples can run with both WebGL and WebGPU, with a few exceptions. For example, more advanced demos like the WebGPU Compute Bitonic Sort are exclusive to WebGPU.

Not necessarily. The TSL Transpiler can handle most of the heavy lifting by converting your existing GLSL code to TSL. Beyond that, it’s mostly about adding a few conditionals to load the appropriate TSL functions depending on the material requirements.

Yes! It’s a great starting point. You can either extend it directly or use the same pattern to build your own custom material logic.

3 Likes

This is solid advice, thank you. It’s clearly the way to go, though it’s quite a steep learning curve while I already know GLSL quite well. I’m a bit baffled looking at the fragmented structure of those node operations.

I’ll wait a bit before I flag this as solved, in case someone else comes along with more advice.

1 Like

You can also globally replace or modify built-in shader chunks via THREE.ShaderChunk. This lets you inject changes that apply across all materials using those chunks.. useful for things like custom lighting, fog, or shadow logic. You can also add your own custom chunks and include them where needed.

That said, there’s no built-in structure to prevent these modifications from becoming hard to maintain, especially when patching multiple materials in complex apps.

TSL (Three Shader Language) may offer a cleaner, more structured way to author and compose shader logic, especially for larger projects, but it does require learning a new syntax and understanding how debugging flows through the transpilation layer. If the model takes hold, it could become the long-term standard, and starting early may future-proof your code.

If you’re comfortable exploring that now, it could be a great choice — and more adoption will help improve the ecosystem:
:backhand_index_pointing_right: GitHub · Where software is built
o7

r.e. uniforms (caveat emptor: i used chatGPT to structure my thoughts on this):

:firecracker: The Problem

When using onBeforeCompile, you’re often injecting the same modified shader code into multiple material instances, but:

  • If you inject a new uniform, it must be added to each material’s .uniforms object individually.
  • Three.js reuses shader programs across materials if the GLSL code is identical — so uniforms can accidentally get shared or reused improperly.
  • It becomes fragile if you don’t clone the material or manually track uniform state per instance.

That’s a valid concern — when using onBeforeCompile, any uniforms you inject must be attached to the material manually, and if you reuse the same shader logic across instances, it’s easy to unintentionally share state. This isn’t strictly a fault of onBeforeCompile, but more about how Three.js handles shader program reuse and uniform binding.

A reliable pattern is to:

  • Clone the material per instance if needed (material.clone()).
  • Add your custom uniforms to material.userData.myUniforms, and inject them into the shader in onBeforeCompile.
  • Use material.onBeforeRender to update the uniform value per draw call if needed.
material.onBeforeCompile = (shader) => {
  shader.uniforms.myTime = { value: 0.0 };
  shader.fragmentShader = shader.fragmentShader.replace(
    '#include <dithering_fragment>',
    'gl_FragColor.rgb += sin(myTime * 2.0);\n#include <dithering_fragment>'
  );
  material.userData.shader = shader;
};

material.onBeforeRender = (renderer, scene, camera, geometry, object) => {
  material.userData.shader.uniforms.myTime.value = performance.now() * 0.001;
};

This gives you per-material and even per-object control, while still leveraging onBeforeCompile. It’s a bit of extra ceremony, but very flexible.

TSL (Three Shader Language) does not magically solve the per-material uniform isolation issue. It makes authoring and composing shader code cleaner, but uniform management remains your responsibility, especially when you want different uniform values per material instance.

3 Likes

The canonical answer here is to use TSL. With TSL you don’t have to really understand shaders, everything just works.

If for whatever reason, you are unable to use TSL, i would suggest this approach. Basically, you don’t have to modify the shaders when they are being compiled, you can assemble them ahead of time, the same way you would write any ShaderMaterial. This way you at least can avoid the dreaded shader.vertexShader = shader.vertexShader.replace('#include <yikes>')

I think my chunk material allows for exactly this. If you can write a single instance of a ShaderMaterial and give it or instantiate it with many different values, you can do that with a ShaderMaterial that is a copy of the PhongMaterial as well.

1 Like

FWIW i think this solves your issue.

class MyMaterial extends PhongMaterial {
  //every instance of this class has it's own copy of this object - YOUR uniforms 
  private _myUniforms = {
    uColor: {value: new Color()},
    uAnimal: {value: 0 },
    uSomeNumber: { value: 1 }
  }
  constructor(color:string, animal: 'cat' | 'dog', someNumber = 5){
     super()
     //when instantiating you can pass your uniform values in
     this.uniforms.uColor.value.set(color)
     this.uniforms.uAnimal.value = animal === 'cat' ? 0 : 1
     this.uniforms.uSomeNumber.value = someNumber
     
     //any instance of this material will extend the built in uniforms with your uniforms
     this.onBeforeCompile = shader=>{
        shader.uniforms = {...shader.uniforms, this._myUniforms }
     }
  }
   //nice clean interace so you dont have to go shader.uniforms.uMyGod.value just myGod
   set animal(v:Animal) { this._myUniforms.uAnimal.value = v === 'cat' ? 0 : 1 }
   get animal(){ return this._myUniforms.uAnimal.value}
}

Possibly you might need to fiddle with customProgramCacheKey, i imagine like this:

...
  constructor(color:string, animal: 'cat' | 'dog', someNumber = 5){
     super()
     this.customProgramCacheKey = ()=>'foobar'
...

But again, since the ShaderMaterial just works:trade_mark: i’d use that.

I would strongly advise against this pattern.

1 Like

I would strongly advise against this pattern.

Not necessarily disagreeing, but how come?

One reason I can think of for using this pattern is that onBeforeRender is only called when an object is in frustum / passes .visible check.

It’s too convoluted. Back in the day the GLTFLoader used this pattern, changing it removed a lot of code.

Think of it it this way. If you can do:

const myMat = new MeshBasicMaterial()
myMat.color.set(1,0,0)

Or

const myMat = new ShaderMaterial({uniforms:{ myUniform: { value:1}})
myMat.uniforms.myUniform.value = 0
  • Why not handle this case the same way? Ie. why aren’t you using onBeforeRender to change a ShaderMaterial or MeshPhongMaterial?
  • Why expose all of the uniforms, shaders and who knows what else could be on that object? (built in uniforms are already handled, so you might clobber something)
  • Not sure if this is still the case but userData was notoriously hard to type correctly.

Imagine you have 1000 boxes that are each changing color randomly, but only 10 of them are in view at any given time..

using onBeforeRender to assign their color can avoid doing the extra 990 random color computations.

..just a contrived counterpoint, but conceivable in practice.

There isn’t another way to check if objects have passed frustum check afaik, and regardless, at the time you could check it in userspace, it would have already passed, and been rendered.

I don’t think the 1000 random color computations would impact anything that much. But again, logically, would you then use onBeforeRender to change any property on anything that can be drawn? Not even the examples seem to do that.

FWIW, onBeforeRender is my contribution to three.js, and i think it was often used in the wrong way.

1 Like

Things like color, metalness etc are static, so it’s not necessary.

Like I said.. a somewhat contrived case, but onBeforeRender IS actually doing something very useful imo. It’s a hook into the rendering pipeline after culling.

It’s too convoluted. Back in the day the GLTFLoader used this pattern, changing it removed a lot of code.

Think of it it this way. If you can do:

const myMat = new MeshBasicMaterial()
myMat.color.set(1,0,0)

Or

const myMat = new ShaderMaterial({uniforms:{ myUniform: { value:1}})
myMat.uniforms.myUniform.value = 0
  • Why not handle this case the same way? Ie. why aren’t you using onBeforeRender to change a ShaderMaterial or MeshPhongMaterial?
  • Why expose all of the uniforms, shaders and who knows what else could be on that object? (built in uniforms are already handled, so you might clobber something)
  • Not sure if this is still the case but userData was notoriously hard to type correctly.

Essentially:

class MyMaterial extends MeshPhongMaterial {
   myInputs = {
     color: {value: new Color},
     texture: {value: null},
     threshold: { value: 1.5 }
   }
   constructor(){ ... }
}

const myMat = new MyMaterial()
myMat.myInputs.color.value.set(1,1,1) // this is a Color
myMat.myInputs.threshold.value = 'Elephant' //ERROR

With userData i think this falls apart.

Hm, what if you have a game, in which you can shoot a gun, which samples the color of some object. The object is not visible, maybe its behind you, and this color sampling bullet can bounce off a wall. The object’s color in your case would not be computed, so your gun would sample a wrong color.

Agreed. That would specifically be a broken/bad use case for onBeforeRender.

But doing a animating color hue shift is not super cheap though.. so animating the value only on visible objects seems like a win.

To be fair, i think your example is a good usage of onBeforeRender in terms of the value you are obtaining (time, random color etc).

but this part is the issue:

material.userData.shader.uniforms.myTime.value = ...

it would be better as:

this._myTime.value = ...

Even if its in onBeforeRender

Yeah I agree. And for that matter.. maybe something using getters (which would thus only be .gotten/computed at onBeforeRender time automatically… might be a cleaner approach.. but i’m not sure if that works internally. I don’t know if the renderer has to speculatively retrieve all uniform values even if the object is not being rendered. )

like:

myUniform:{
  get value(){ 
     // expensive calculation here... presumably this would only be .gotten by the renderer if 
     // frustum check passed?
   }
}

(My brain sizzles when trying to think of how/whether this level of granularity works in TSL.)