Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 3 additions & 13 deletions examples/jsm/objects/Sky.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,16 +93,11 @@ Sky.SkyShader = {
const float e = 2.71828182845904523536028747135266249775724709369995957;
const float pi = 3.141592653589793238462643383279502884197169;

// wavelength of used primaries, according to preetham
const vec3 lambda = vec3( 680E-9, 550E-9, 450E-9 );
// this pre-calculation replaces older TotalRayleigh(vec3 lambda) function:
// (8.0 * pow(pi, 3.0) * pow(pow(n, 2.0) - 1.0, 2.0) * (6.0 + 3.0 * pn)) / (3.0 * N * pow(lambda, vec3(4.0)) * (6.0 - 7.0 * pn))
const vec3 totalRayleigh = vec3( 5.804542996261093E-6, 1.3562911419845635E-5, 3.0265902468824876E-5 );

// mie stuff
// K coefficient for the primaries
const float v = 4.0;
const vec3 K = vec3( 0.686, 0.678, 0.666 );
// MieConst = pi * pow( ( 2.0 * pi ) / lambda, vec3( v - 2.0 ) ) * K
const vec3 MieConst = vec3( 1.8399918514433978E14, 2.7798023919660528E14, 4.0790479543861094E14 );

Expand Down Expand Up @@ -134,7 +129,7 @@ Sky.SkyShader = {

vSunE = sunIntensity( dot( vSunDirection, up ) );

vSunfade = 1.0 - clamp( 1.0 - exp( ( sunPosition.y / 450000.0 ) ), 0.0, 1.0 );
vSunfade = 1.0 - clamp( 1.0 - exp( dot( vSunDirection, up ) ), 0.0, 1.0 );

float rayleighCoefficient = rayleigh - ( 1.0 * ( 1.0 - vSunfade ) );

Expand All @@ -161,9 +156,6 @@ Sky.SkyShader = {
// constants for atmospheric scattering
const float pi = 3.141592653589793238462643383279502884197169;

const float n = 1.0003; // refractive index of air
const float N = 2.545E25; // number of molecules per unit volume for air at 288.15K and 1013mb (sea level -45 celsius)

// optical length at zenith for molecules
const float rayleighZenithLength = 8.4E3;
const float mieZenithLength = 1.25E3;
Expand Down Expand Up @@ -221,11 +213,9 @@ Sky.SkyShader = {
float sundisk = smoothstep( sunAngularDiameterCos, sunAngularDiameterCos + 0.00002, cosTheta );
L0 += ( vSunE * 19000.0 * Fex ) * sundisk;

vec3 texColor = ( Lin + L0 ) * 0.04 + vec3( 0.0, 0.0003, 0.00075 );

vec3 retColor = pow( texColor, vec3( 1.0 / ( 1.2 + ( 1.2 * vSunfade ) ) ) );
vec3 texColor = ( Lin + L0 );

gl_FragColor = vec4( retColor, 1.0 );
gl_FragColor = vec4( texColor, 1.0 );

#include <tonemapping_fragment>
#include <colorspace_fragment>
Expand Down
8 changes: 3 additions & 5 deletions examples/jsm/objects/SkyMesh.js
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ class SkyMesh extends Mesh {

// varying sun fade

const sunfade = float( 1.0 ).sub( clamp( float( 1.0 ).sub( exp( this.sunPosition.y.div( 450000.0 ) ) ), 0, 1 ) );
const sunfade = float( 1.0 ).sub( clamp( float( 1.0 ).sub( exp( dot( sunDirection, this.upUniform ) ) ), 0, 1 ) );
vSunfade.assign( sunfade );

// varying vBetaR
Expand Down Expand Up @@ -232,11 +232,9 @@ class SkyMesh extends Mesh {
const sundisk = smoothstep( sunAngularDiameterCos, sunAngularDiameterCos.add( 0.00002 ), cosTheta );
L0.addAssign( vSunE.mul( 19000.0 ).mul( Fex ).mul( sundisk ) );

const texColor = add( Lin, L0 ).mul( 0.04 ).add( vec3( 0.0, 0.0003, 0.00075 ) );
const texColor = add( Lin, L0 );

const retColor = pow( texColor, vec3( float( 1.0 ).div( float( 1.2 ).add( vSunfade.mul( 1.2 ) ) ) ) );

return vec4( retColor, 1.0 );
return vec4( texColor, 1.0 );

} )();

Expand Down
14 changes: 8 additions & 6 deletions examples/jsm/objects/Water.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ import {
UniformsUtils,
Vector3,
Vector4,
WebGLRenderTarget

WebGLRenderTarget,
HalfFloatType
} from 'three';

/**
Expand Down Expand Up @@ -84,7 +86,7 @@ class Water extends Mesh {

const mirrorCamera = new PerspectiveCamera();

const renderTarget = new WebGLRenderTarget( textureWidth, textureHeight );
const renderTarget = new WebGLRenderTarget( textureWidth, textureHeight, { type: HalfFloatType } );

const mirrorShader = {

Expand Down Expand Up @@ -193,19 +195,19 @@ class Water extends Mesh {
float distance = length(worldToEye);

vec2 distortion = surfaceNormal.xz * ( 0.001 + 1.0 / distance ) * distortionScale;
vec3 reflectionSample = vec3( texture2D( mirrorSampler, mirrorCoord.xy / mirrorCoord.w + distortion ) );
vec3 reflectionSample = vec3( texture2D( mirrorSampler, clamp( mirrorCoord.xy / mirrorCoord.w + distortion, 0.0, 1.0 ) ) );

float theta = max( dot( eyeDirection, surfaceNormal ), 0.0 );
float rf0 = 0.3;
float rf0 = 0.02;
float reflectance = rf0 + ( 1.0 - rf0 ) * pow( ( 1.0 - theta ), 5.0 );
vec3 scatter = max( 0.0, dot( surfaceNormal, eyeDirection ) ) * waterColor;
vec3 albedo = mix( ( sunColor * diffuseLight * 0.3 + scatter ) * getShadowMask(), ( vec3( 0.1 ) + reflectionSample * 0.9 + reflectionSample * specularLight ), reflectance);
vec3 albedo = mix( ( sunColor * diffuseLight * 0.3 + scatter ) * getShadowMask(), ( reflectionSample + specularLight ), reflectance);
vec3 outgoingLight = albedo;
gl_FragColor = vec4( outgoingLight, alpha );

#include <tonemapping_fragment>
#include <colorspace_fragment>
#include <fog_fragment>
#include <fog_fragment>
}`

};
Expand Down
8 changes: 4 additions & 4 deletions examples/jsm/objects/WaterMesh.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {
Color,
Mesh,
Vector3,
MeshLambertNodeMaterial
MeshBasicNodeMaterial
} from 'three/webgpu';

import { Fn, add, cameraPosition, div, normalize, positionWorld, sub, time, texture, vec2, vec3, max, dot, reflect, pow, length, float, uniform, reflector, mul, mix, diffuseColor } from 'three/tsl';
Expand Down Expand Up @@ -32,7 +32,7 @@ class WaterMesh extends Mesh {
*/
constructor( geometry, options ) {

const material = new MeshLambertNodeMaterial();
const material = new MeshBasicNodeMaterial();

super( geometry, material );

Expand Down Expand Up @@ -166,10 +166,10 @@ class WaterMesh extends Mesh {
this.add( mirrorSampler.target );

const theta = max( dot( eyeDirection, surfaceNormal ), 0.0 );
const rf0 = float( 0.3 );
const rf0 = float( 0.02 );
const reflectance = mul( pow( float( 1.0 ).sub( theta ), 5.0 ), float( 1.0 ).sub( rf0 ) ).add( rf0 );
const scatter = max( 0.0, dot( surfaceNormal, eyeDirection ) ).mul( this.waterColor );
const albedo = mix( this.sunColor.mul( diffuseLight ).mul( 0.3 ).add( scatter ), mirrorSampler.rgb.mul( specularLight ).add( mirrorSampler.rgb.mul( 0.9 ) ).add( vec3( 0.1 ) ), reflectance );
const albedo = mix( this.sunColor.mul( diffuseLight ).mul( 0.3 ).add( scatter ), mirrorSampler.rgb.add( specularLight ), reflectance );

return albedo;

Expand Down
Binary file modified examples/screenshots/webgl_shaders_ocean.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified examples/screenshots/webgl_shaders_sky.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified examples/screenshots/webgpu_ocean.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified examples/screenshots/webgpu_sky.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 4 additions & 4 deletions examples/webgl_shaders_ocean.html
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,12 @@

//

renderer = new THREE.WebGLRenderer();
renderer = new THREE.WebGLRenderer( { antialias: true } );
renderer.setPixelRatio( window.devicePixelRatio );
renderer.setSize( window.innerWidth, window.innerHeight );
renderer.setAnimationLoop( animate );
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 0.5;
renderer.toneMappingExposure = 0.0025;
container.appendChild( renderer.domElement );

//
Expand Down Expand Up @@ -99,12 +99,12 @@
const skyUniforms = sky.material.uniforms;

skyUniforms[ 'turbidity' ].value = 10;
skyUniforms[ 'rayleigh' ].value = 2;
skyUniforms[ 'rayleigh' ].value = 1;
skyUniforms[ 'mieCoefficient' ].value = 0.005;
skyUniforms[ 'mieDirectionalG' ].value = 0.8;

const parameters = {
elevation: 2,
elevation: 15,
azimuth: 180
};

Expand Down
2 changes: 1 addition & 1 deletion examples/webgl_shaders_sky.html
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@
renderer.setPixelRatio( window.devicePixelRatio );
renderer.setSize( window.innerWidth, window.innerHeight );
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 0.5;
renderer.toneMappingExposure = 0.0025;
document.body.appendChild( renderer.domElement );

const controls = new OrbitControls( camera, renderer.domElement );
Expand Down
8 changes: 4 additions & 4 deletions examples/webgpu_ocean.html
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,12 @@

//

renderer = new THREE.WebGPURenderer();
renderer = new THREE.WebGPURenderer( { antialias: true } );
renderer.setPixelRatio( window.devicePixelRatio );
renderer.setSize( window.innerWidth, window.innerHeight );
renderer.setAnimationLoop( render );
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 0.5;
renderer.toneMappingExposure = 0.0025;
renderer.inspector = new Inspector();
container.appendChild( renderer.domElement );

Expand Down Expand Up @@ -104,12 +104,12 @@
scene.add( sky );

sky.turbidity.value = 10;
sky.rayleigh.value = 2;
sky.rayleigh.value = 1;
sky.mieCoefficient.value = 0.005;
sky.mieDirectionalG.value = 0.8;

const parameters = {
elevation: 2,
elevation: 15,
azimuth: 180
};

Expand Down
2 changes: 1 addition & 1 deletion examples/webgpu_sky.html
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@
renderer.setSize( window.innerWidth, window.innerHeight );
renderer.setAnimationLoop( animate );
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 0.5;
renderer.toneMappingExposure = 0.0025;
Copy link
Collaborator

Choose a reason for hiding this comment

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

We normally not use such low tone mapping exposure values in our demos. How is this setting compatible when combining the sky with a PBR/IBL scene that requires a higher exposure?

Copy link
Owner Author

Choose a reason for hiding this comment

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

Turns out the sun is very bright 🙃

If we want to be physically accurate seems like it's the only option.

Copy link
Collaborator

@Mugen87 Mugen87 Nov 21, 2025

Choose a reason for hiding this comment

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

I just fear this change might break existing usage and apps won't get the exposure right for their scenes.

What do you think of a new flag like physicallyCorrectLighting that enables the new code path?

Copy link
Owner Author

Choose a reason for hiding this comment

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

I actually I don't think I've ever seen anyone using Sky in the wild... 🤔 Have you?

Only Shota but he did his own.

Copy link
Collaborator

@Mugen87 Mugen87 Nov 21, 2025

Choose a reason for hiding this comment

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

There were some questions related to THREE.Sky at the forum and stackoverflow in the past so the effect is used for sure e.g.:

https://discourse.threejs.org/t/sky-shader-example/13653
https://discourse.threejs.org/t/sky-js-shader-example-why-doesnt-it-use-a-cube-map-camera/16602

TBH, I have no good feeling that the effect requires now such a special tone mapping exposure.

Copy link
Owner Author

Choose a reason for hiding this comment

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

I'll ask Claude to research how all the other engines handle this problem 👀

Copy link
Owner Author

@mrdoob mrdoob Nov 21, 2025

Choose a reason for hiding this comment

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

Actually, I'll test asking https://jules.google/ now that it can be repoless.

Copy link
Owner Author

@mrdoob mrdoob Nov 21, 2025

Choose a reason for hiding this comment

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

Here's what Jules said:

Unreal, Unity, Filament, Godot

I have researched how other major rendering engines (Unreal Engine, Unity HDRP, Filament, Godot 4) handle the high intensity of the Sun and Sky.

Here is the summary of how they handle it:

1. They use Physical Units (High Values)

All modern PBR engines explicitly use physical units for lighting. They do not clamp or arbitrarily reduce the sun's intensity.

  • Unreal Engine: Uses Lux for Directional Lights. The default Sun intensity is ~100,000 - 120,000 Lux.

  • Unity HDRP: Uses Lux. The sun is consistently set to ~100,000 Lux.

  • Filament: Uses Lux. Sun is ~100,000 Lux.

  • Godot 4: Has moved to physical units, also using ~100,000 Lux for the sun.

2. They rely on Physical Camera Exposure (EV)

To handle these massive values, these engines rely on a physical camera model.

  • The scene is rendered with a camera Exposure Value (EV100) appropriate for daylight (typically EV 14-15).

  • Auto-Exposure (Eye Adaptation): Engines like Unreal and Unity heavily rely on auto-exposure to dynamically adjust the EV based on the scene's average luminance, allowing the camera to transition from bright outdoors (EV 15) to dark indoors (EV 5-8).

3. The "Dark Scene" Issue (Compatibility)

The concern raised in the PR ("mixing... with a PBR/IBL scene that requires a higher exposure") arises from mixing Physical units with Legacy/Arbitrary units.

  • In a fully physical workflow (Unreal/Unity), you cannot use "Intensity 1" lights alongside a "Intensity 100,000" Sun.

  • If you expose for the Sun (EV 15), an "Intensity 1" light becomes invisible.

  • The Solution in other engines: Users must set all scene lights to physical values. A standard 60W lightbulb is not Intensity 1, but ~800 Lumens (approx ~100 candela).

  • If the user wants to use the physical Sun, they must upgrade their other lights to physical intensities, or the scene will indeed look black.

4. Technical Note: "Pre-exposure" (Important for HalfFloat)

Google's Filament engine documentation notes a critical implementation detail relevant to HalfFloatType render targets (which the PR mentions):

  • FP16 (HalfFloat) maxes out at 65,504.

  • A Sun intensity of 100,000+ will clip if written directly to a HalfFloat buffer.

  • Solution: Filament "pre-exposes" lights. Before rendering, they multiply the Light Intensity by the Camera Exposure value: EffectiveLightIntensity = LightIntensity * Exposure (e.g., 100,000 * 0.0025 = 250).

  • This allows the high-dynamic-range lighting to fit comfortably within the precision and range limits of HalfFloat buffers while maintaining physical accuracy.

Conclusion for Three.js PR

  • The PR's approach of using 0.0025 exposure is physically correct for a Sun intensity of ~100k.

  • Compatibility: To support legacy scenes (where lights are Intensity ~1), you generally have two options:

    1. Physical Mode: Require the user to use physical intensities for all lights (Sun ~100k, Bulbs ~800lm, Exposure ~0.0025).

    2. Legacy Mode (Scaling): Provide a intensityScale on the Sky/Sun object to scale the 100k down to ~1 (e.g., 0.00001 scale) so it matches legacy lighting environments without changing global exposure.

The "Pre-exposure" optimization from Filament might be a good addition to the PR to ensure the 100k sun doesn't clip in the HalfFloat render target before tone mapping.

Blender, Maya, 3DS Max

I have researched how Blender and other major 3D software (Maya/Arnold, 3ds Max/V-Ray) handle sun intensity and exposure.

Here is the summary:

1. They Use Physical Units (High Intensity)

Just like the game engines, the major 3D software packages generally use physical units for the sun, resulting in high intensity values.

  • Blender:

    • The Sun Lamp strength is measured in Watts per square meter ($W/m^2$). A physically accurate value for sunlight is ~1000 $W/m^2$ (roughly 120,000 Lux).

    • The Nishita Sky texture (Blender's physical sky) outputs these physical values by default.

    • Note: The default Sun Lamp object in Blender starts at a strength of 1.0 (non-physical, very dim). Users are expected to increase this to ~1000 for physical accuracy, or use the Sky Texture which is physical by default.

  • Maya (Arnold): Uses physical sky models where intensity is often left at default (physical) or adjusted slightly (e.g., to 4-5), but relies on camera exposure.

  • 3ds Max (V-Ray): The VRaySun is extremely bright by default. Users are explicitly taught to set the Physical Camera Exposure to roughly EV 13-15 (Sunny 16 rule) to prevent the image from being blown out.

2. They Rely on Exposure Controls

To handle the high dynamic range of a physical sun, all these tools rely on "Exposure" settings in the render or camera properties.

  • Blender: Uses Filmic (and recently AgX) color management. Users are advised to lower the Exposure value in the Color Management settings (often by ~6 stops) when using a physical sun/sky to get a properly exposed image.

  • V-Ray/Corona/Arnold: Strongly link the sun intensity to the Physical Camera settings (ISO, Shutter Speed, f-stop). If you don't use a physical camera with correct exposure, the render is white.

3. Compatibility with Standard Lights

The "dark scene" issue is standard across the industry when mixing physical sunlight with arbitrary point lights.

  • If you expose for the Sun (EV 15), a standard "Intensity 1" point light becomes invisible.

  • The Solution: Users must also set their artificial lights to physical values (e.g., a light bulb is not 1 Watt in Blender, but often needs to be set to its radiant flux in Watts, which might be much higher effectively depending on efficacy, or users crank values until it looks right relative to the sun).

  • Blender Add-ons: Popular add-ons like "Photographer" automatically handle this by letting users set exposure and light units physically (Lumens, Candelas) to match the physical sun.

Conclusion

The industry standard approach (Blender, V-Ray, Arnold) matches what game engines (Unreal, Unity) do:

  1. Sun Intensity: High, physical values (~100k Lux / 1000 $W/m^2$).

  2. Exposure: Low exposure values (or High EV) to compensate.

  3. Tone Mapping: Filmic/AgX/ACES to handle the range.

The "issue" of the sky being too bright is actually the correct physical behavior, and the solution is to expose for the sky and upgrade other lights to match.

Sounds like everyone relies on exposure.

I like the idea of doing something like:

renderer.tonemappingExposure = THREE.AutoExposure;

Copy link
Collaborator

@Mugen87 Mugen87 Nov 22, 2025

Choose a reason for hiding this comment

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

It's good to know that other engines do it this way but still we must be prepared on questions from the user side.

How would you suggest users should migrate from the first to the second fiddle?

r182dev: https://jsfiddle.net/8f4bmc9u/
r182dev + this PR: https://jsfiddle.net/qd71h04t/

As you can see, the sky gets almost complete white with an exposure of 0.5. If I change the value to 0.0025, the mesh gets black:

https://jsfiddle.net/4w6q7khn/

I have tried to generate an environment map from the sky via CubeCamera but you don't get a proper result as well: https://jsfiddle.net/8zef3b1u/

You must noticeably increase the exposure to see an effect which means the result is not consistent.

Copy link
Collaborator

Choose a reason for hiding this comment

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

renderer.tonemappingExposure = THREE.AutoExposure;

Shouldn't auto-exposure be performed in post-processing?
I think we will need parameters for eye adaptation and limits too.

r182dev + this PR: https://jsfiddle.net/qd71h04t/

Just a feeling about this: shouldn't there be less exposure at midday? Apparently, the same exposure is being used at sunset and at midday when the sunlight is most intense to show the same luminance intensity?

renderer.inspector = new Inspector();
document.body.appendChild( renderer.domElement );

Expand Down