mrdoob / three.js

JavaScript 3D Library.
https://threejs.org/
MIT License
102.04k stars 35.33k forks source link

More flexible material override #26732

Open OndrejSpanel opened 1 year ago

OndrejSpanel commented 1 year ago

Description

I want to render my scene for reflections. For that I want to replace all materials with simpler variants. Currently the only possible override is scene.overrideMaterial, but sometimes the materials in scene are so different it is hard to replace them with one material only.

Solution

I suggest extending this by adding a new member - scene.mapMaterial, which would be a function receiving material and returning material, allowing to map any material and replacing is as desired (often one could implement it using a simple hashmap holding alternative representations of the material).

Alternatives

One possible alternative is to clone the whole scene and replace the materials on all objects. Another alternative is to control material detail using material defines or uniforms, but that requires updating all materials at least twice in each frame.

Additional context

To avoid additional tests, current functionality of overrideMaterial === null ? renderItem.material : overrideMaterial would be handled by a default value of mapMaterial, containing this code wrapped in a function.

The solution would allow functionality similar to what is currently done for shadow maps in getDepthMaterial function.

If the idea seems reasonable, I will be glad to prepare PR.

donmccurdy commented 1 year ago

I ran into a similar problem recently, and would like to share the use case here. Consider an editor-like application using THREE.GridHelper, and offering different viewport shading options as Blender does:

  1. Rendered
  2. Solid / Matcap / Flat Shaded
  3. Wireframe

I'd thought this would be easy:

// 1. Rendered — use original material
scene.overrideMaterial = null;

// 2. Solid — use matcap material
scene.overrideMaterial = new MeshMatcapMaterial({ flatShading: true });

// 3. Wireframe — use wireframe material
scene.overrideMaterial = new MeshBasicMaterial({ wireframe: true });

However, (3) breaks GridHelper with errors like...

[.WebGL-0x1280a426900] GL_INVALID_OPERATION: Vertex buffer is not big enough for the draw call

... probably because it's a LineSegments object and not a mesh. And ideally I'd exclude the editor's own UI, like helpers, from the override. Something like a mapping function would certainly work here. Or perhaps a mapping based on Layers?

I'm unsure whether this is something we should have an API for... would callbacks on every material add too much overhead? Interested what others think here.

Tangentially related:

LeviPesin commented 1 year ago

Or perhaps a mapping based on Layers?

Maybe overrideMaterial could be made an array, one material for each layer?

donmccurdy commented 1 year ago

We could borrow a trick from CSS, letting any material override the .overrideMaterial ...

scene.overrideMaterial = new MeshMatcapMaterial({ flatShading: true });

// ...

gridHelper.material.important = true;

😱

donmccurdy commented 1 year ago

I'm worried though, that we would be taking something the user can do in a one-time traverse() to replace materials...

const originals = new Map();

// override
scene.traverse((object) => {
  if (!object.material) return;
  const material = object.material;
  originals.set(object, material);
  object.material = overrideMaterial;
});

// reset
for (const [object, material] of originals.entries()) {
  object.material = material;
}

... and turning that into a one-time-per-frame operation in the engine. We probably do not want that. Though it is really nice to be able to override the material without reassigning it, e.g. in an editor where the user may change an objects materials while in a wireframe-only view.

OndrejSpanel commented 5 months ago

... and turning that into a one-time-per-frame operation in the engine.

The operation is already there, in the renderObjects the renderer is doing:

const material = overrideMaterial === null ? renderItem.material : overrideMaterial;

It would change to:

const material = scene.mapMaterial(renderItem.material, renderItem.object)

Scene.mapMaterial would be initialized in the Sceneconstructor as:

this.mapMaterial = (material) => this.overrideMaterial === null  ? material : this.overrideMaterial

It is a small change, making material override much more flexible, suitable for implementing custom shadow map rendering, env, map rendering and similiar tasks.

Oletus commented 4 months ago

Would you accept a PR along the lines suggested by @OndrejSpanel in the last comment? Having an overridable function in Scene seems like a good way to implement this.

Mugen87 commented 4 months ago

How do you restore the original implementation of a callback after the override? Now you can do this:

scene.overrideMaterial = materialNormal;

renderer.setRenderTarget( normalRenderTarget );
renderer.clear();
renderer.render( scene, camera );
renderer.setRenderTarget( null );

scene.overrideMaterial = null;

With a callback it would be:

scene.mapMaterial = applyCustomOverride;

renderer.setRenderTarget( normalRenderTarget );
renderer.clear();
renderer.render( scene, camera );
renderer.setRenderTarget( null );

scene.mapMaterial = ???; // null does not work here

Besides, I would rename mapMaterial to something like onMaterialOverride().

const material = scene.mapMaterial(renderItem.material, renderItem.object)

This code in WebGLRenderer does not work since scene can be of type Mesh or any other 3D object. You have to move mapMaterial() to Object3D or check if the isScene flag is true. The latter should look like so using onMaterialOverride() :

const overrideMaterial = scene.isScene === true ? scene.onMaterialOverride( object ) : null;
const material = overrideMaterial === null ? renderItem.material : overrideMaterial;

The default implementation in Scene is:

onMaterialOverride( /* object = null */ ) {

    return this.overrideMaterial;

}

However, the question remains how you toggle the override.

Mugen87 commented 4 months ago

One approach is to use this pattern know from other parts of the code base:

const oldMaterialOverride = scene.onMaterialOverride;
scene.onMaterialOverride = applyCustomOverride;

// restore

scene.onMaterialOverride = oldMaterialOverride;

I wonder if there is a nicer API though...