Skip to content

Conversation

@mrdoob
Copy link
Owner

@mrdoob mrdoob commented Dec 1, 2025

Description

For transparent materials with additive blending, opacity now scales HDR values before tonemapping rather than after. This preserves highlight proportions better.

  • Before: tonemap(HDR) * opacity → bright highlights get compressed then scaled down
  • After: tonemap(HDR * opacity) → scaling happens in linear HDR space, like reducing exposure
Before After
Screenshot 2025-12-01 at 06 51 53 Screenshot 2025-12-01 at 06 51 48

Changes

  • opaque_fragment.glsl.js: Add BLENDING_ADDITIVE path that premultiplies outgoingLight by opacity
  • WebGLPrograms.js: Add blendingAdditive parameter
  • WebGLProgram.js: Add BLENDING_ADDITIVE define

@mrdoob mrdoob added this to the r182 milestone Dec 1, 2025
@github-actions
Copy link

github-actions bot commented Dec 1, 2025

📦 Bundle size

Full ESM build, minified and gzipped.

Before After Diff
WebGL 350.26
83.06
350.5
83.12
+242 B
+64 B
WebGPU 614.39
170.61
614.39
170.61
+0 B
+0 B
WebGPU Nodes 612.99
170.34
612.99
170.34
+0 B
+0 B

🌳 Bundle size after tree-shaking

Minimal build including a renderer, camera, empty scene, and dependencies.

Before After Diff
WebGL 482.25
117.84
482.49
117.9
+241 B
+64 B
WebGPU 685.7
186.38
685.7
186.38
+0 B
+0 B
WebGPU Nodes 635.54
173.56
635.54
173.56
+0 B
+0 B

gl_FragColor = vec4( outgoingLight, diffuseColor.a );
#ifdef BLENDING_ADDITIVE
gl_FragColor = vec4( outgoingLight * diffuseColor.a, 1.0 );
Copy link
Collaborator

Choose a reason for hiding this comment

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

Why is this approach just used for additive blending and not for all use cases?

Copy link
Collaborator

@Mugen87 Mugen87 Dec 1, 2025

Choose a reason for hiding this comment

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

@WestLangley Does this change interfere when premultipliedAlpha is set to true?

Copy link
Collaborator

Choose a reason for hiding this comment

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

There is something wrong with the model. Additive blending seems to have been added to the example after-the-fact. We have techniques to render glass correctly using normal blending.

Copy link
Owner Author

Choose a reason for hiding this comment

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

We have techniques to render glass correctly using normal blending.

I tried using transmission but the render is not as crisp (compare ROLEX and the text under):

Screenshot 2025-12-01 at 22 32 21

Copy link
Owner Author

Choose a reason for hiding this comment

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

@Mugen87

Why is this approach just used for additive blending and not for all use cases?
Does this change interfere when premultipliedAlpha is set to true?

Here you go:

Additive Blending & Pre-multiplication

Three approaches, same blend result, different tone mapping:

Approach Shader Output Blend Function Blend Result What gets tone-mapped
Default (rgb, 0.4) src * srcAlpha + dst rgb * 0.4 + dst toneMap(rgb) then * 0.4 in blend
premultipliedAlpha: true (rgb, 0.4) src * 1 + dst rgb + dst toneMap(rgb) — alpha ignored!
BLENDING_ADDITIVE shader (rgb * 0.4, 1.0) src * srcAlpha + dst rgb * 0.4 + dst toneMap(rgb * 0.4)

Why only additive blending?

For normal alpha blending (src * a + dst * (1-a)), pre-multiplying in the shader would square the alpha — breaking the math. Additive blending only uses alpha as a source multiplier, so moving the multiplication into the shader works.

The core problem:

WebGL blending happens after the fragment shader. So tone mapping (in shader) always happens before blending. There's no way to blend in HDR space without post-processing.

The BLENDING_ADDITIVE change moved the * alpha before tone mapping, which changes the visual result because toneMap(x) * a ≠ toneMap(x * a).

Copy link
Collaborator

Choose a reason for hiding this comment

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

Hm... the words sound plausible, but I'm having a difficult time anchoring them to fundamentals I understand. Does this make sense to others, and/or do we have a reference beyond the LLM? Notably, if I put "premultiply alpha before tonemapping" or "why not premultiply alpha before tonemapping" into one, it is very happy to respond with well-written words arguing that the opposite choices are correct, preferred, and conventional.

The change is similar enough to tone mapping in a later pass, so that's encouraging, but turning it on globally for additive blending, and without reference to material.premultipliedAlpha, is confusing me.

Copy link
Owner Author

Choose a reason for hiding this comment

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

Hm... the words sound plausible, but I'm having a difficult time anchoring them to fundamentals I understand. Does this make sense to others, and/or do we have a reference beyond the LLM? Notably, if I put "premultiply alpha before tonemapping" or "why not premultiply alpha before tonemapping" into one, it is very happy to respond with well-written words arguing that the opposite choices are correct, preferred, and conventional.

This is what I'm trying to solve:

// Rendering to LDR framebuffer (RGBA8) with HDR values

outgoingLight = 5.0, alpha = 0.1
Desired contribution: 5.0 * 0.1 = 0.5

Without BLENDING_ADDITIVE:
- Shader outputs: (5.0, 0.1)
- Clamp RGB to [0,1]: (1.0, 0.1)  ← HDR value lost
- Blend (src * srcAlpha + dst): 1.0 * 0.1 + dst = 0.1 + dst  ← wrong

With BLENDING_ADDITIVE:
- Shader outputs: (0.5, 1.0)  ← RGB = 5.0 * 0.1, Alpha = 1.0
- Clamp RGB to [0,1]: (0.5, 1.0)  ← already in range
- Blend (src * srcAlpha + dst): 0.5 * 1.0 + dst = 0.5 + dst  ← correct

BLENDING_ADDITIVE premultiplies the light contribution and material.premultiplyAlpha is about the asset itself:

Input colors → Lighting calculations → outgoingLight → [BLENDING_ADDITIVE here] → Blend
     ↑                                                                              ↑
premultipliedAlpha                                                          premultipliedAlpha
affects this                                                                affects this

Copy link
Collaborator

@gkjohnson gkjohnson Dec 2, 2025

Choose a reason for hiding this comment

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

This isn't correct. "Additive blending" is additive color and alpha - the alpha cannot just be set to 1.0 because the opacity has already been applied to the rgb channels. This change will break any additive blending on a transparent background. From the "webgl materials blending" demo with "alpha:true" setting passed to WebGLRenderer and a clear background color:

dev this pr
image image

The AI response doesn't make sense to me, either. The only reason you'd only apply this to additive blending is because it would be more obviously wrong in all other blending conditions. If you want to premultiply the alpha before applying tone mapping then we should use premultiplied alpha. And move the tone mapping stage after the "premultiplied alpha" operation (it's currently before). This change is really just adding in premultiplied alpha only for additive blending (with incorrect blend states set) even when the setting is false.

Fundamentally this is an issue of mapping a color to [0, 1] and then multiplying alpha into the color before blending. This will happen with non-tonemapped materials if premultiplied alpha is set to false, as well, since the graphics API will clamp the color before blending. You can see the effect in this fiddle with 25% opacity transparent spheres:

image

left: toneMapped=false, premultipliedAlpha=false, middle: toneMapped=false, premultipliedAlpha=true, right: toneMapped=true, premultipliedAlpha=true

You'll see that the "tone mapped" sphere has the same dull highlight as the non-premultiplied alpha sphere because it tonemaps the color to [0, 1] before applying alpha, as the graphics API does when blending.

Regarding whether it's "correct" to perform tonemapping before or after applying alpha - I don't have any strong opinions. But I believe it's the only way to avoid this problem in a forward rendering shader (unless our tone mapping functions will map to a range outside of [0, 1] which is a different discussion). Really you want to tone map the color after it's been blended to a final color on the screen (eg a postprocessing effect) so either way I think we're dealing with a compromise.

If this is something we want to improve I think we should look at rearranging the order of these shader fragments, though I know these have been arranged in this way for reason:

	#include <tonemapping_fragment>
	#include <colorspace_fragment>
	#include <fog_fragment>
	#include <premultiplied_alpha_fragment>

edit

it is very happy to respond with well-written words arguing that the opposite choices are correct, preferred, and conventional.

I'm generally seeing this kind of thing, as well, with other topics 😅 it can sound very convincing if a topic is unfamiliar and this premultiplied alpha topic is fairly complicated, I think.

Copy link
Owner Author

@mrdoob mrdoob Dec 2, 2025

Choose a reason for hiding this comment

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

Just did another PR that moves premultiplied_alpha before tonemapping: #32449

Seems like the right solution indeed.

Although it's definitely not great that the developer needs to know and set premultiplyAlpha: true if they want a opacity: 0.5 material to not get clamped IBL.

Edit:

If this is something we want to improve I think we should look at rearranging the order of these shader fragments

Oh good. Was just working on just that before I saw your post.

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.

6 participants