Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
1 change: 1 addition & 0 deletions examples/files.json
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,7 @@
"webgpu_depth_texture",
"webgpu_display_stereo",
"webgpu_equirectangular",
"webgpu_hdr",
"webgpu_instance_mesh",
"webgpu_instance_path",
"webgpu_instance_points",
Expand Down
6 changes: 3 additions & 3 deletions examples/jsm/tsl/display/AfterImageNode.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@ class AfterImageNode extends TempNode {
* Constructs a new after image node.
*
* @param {TextureNode} textureNode - The texture node that represents the input of the effect.
* @param {number} [damp=0.96] - The damping intensity. A higher value means a stronger after image effect.
* @param {UniformNode<float>} [damp=0.96] - The damping intensity. A higher value means a stronger after image effect.
*/
constructor( textureNode, damp = 0.96 ) {
constructor( textureNode, damp = float( 0.96 ) ) {

super( 'vec4' );

Expand All @@ -51,7 +51,7 @@ class AfterImageNode extends TempNode {
*
* @type {UniformNode<float>}
*/
this.damp = uniform( damp );
this.damp = damp;

/**
* The render target used for compositing the effect.
Expand Down
Binary file added examples/screenshots/webgpu_hdr.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
199 changes: 199 additions & 0 deletions examples/webgpu_hdr.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
<title>three.js webgpu - HDR Draw</title>
<link type="text/css" rel="stylesheet" href="main.css" />
<style>
#no-hdr {
position: absolute;
font-family: monospace;
font-size: 11px;
font-weight: normal;
text-align: center;
background: #000;
color: #fff;
left: 50%;
transform: translateX(-50%);
padding: 1.5em;
max-width: 600px;
margin: 5em auto 0;
}
</style>
</head>
<body>

<div id="info" style="color: #000">
<a href="https://threejs.org" target="_blank" rel="noopener">threejs</a> - HDR Draw
</div>
<div id="no-hdr" style="display: none">
<div>
The browser says your device or monitor doesn't support HDR.<br />
If you're on a laptop using an external monitor, try the built in
monitor<br />
or, try this site on your phone. Most phones support HDR.
</div>
</div>

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

<script type="module">

import * as THREE from 'three/webgpu';
import { pass, uv, uniform } from 'three/tsl';
import WebGPU from 'three/addons/capabilities/WebGPU.js';
import { afterImage } from 'three/addons/tsl/display/AfterImageNode.js';
import { GUI } from 'three/addons/libs/lil-gui.module.min.js';

import { ExtendedSRGBColorSpace, ExtendedSRGBColorSpaceImpl } from 'three/addons/math/ColorSpaces.js';

const params = {
intensity: uniform( 4.0, 'float' ).setName( 'intensity' ),
hardness: uniform( 0.4, 'float' ).setName( 'hardness' ),
radius: uniform( 0.5, 'float' ).setName( 'radius' ),
afterImageDecay: uniform( 0.985, 'float' ).setName( 'afterImageDecay' ),
};

const hdrMediaQuery = window.matchMedia( '(dynamic-range: high)' );

function updateHDRWarning() {

const displayIsHDR = hdrMediaQuery.matches;
document.querySelector( '#no-hdr' ).style.display = displayIsHDR ? 'none' : '';

}

hdrMediaQuery.addEventListener( 'change', updateHDRWarning );
updateHDRWarning();

if ( WebGPU.isAvailable() === false ) {

document.body.appendChild( WebGPU.getErrorMessage() );
throw new Error( 'No WebGPU support' );

}

// Enable Extended sRGB output color space for HDR presentation
THREE.ColorManagement.define( { [ ExtendedSRGBColorSpace ]: ExtendedSRGBColorSpaceImpl } );
THREE.ColorManagement.workingColorSpace = ExtendedSRGBColorSpace;
Copy link
Collaborator

@donmccurdy donmccurdy Sep 12, 2025

Choose a reason for hiding this comment

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

@RenaudRohlinger The working color space will need to remain Linear-sRGB (the default value). If this causes other issues I think we can work that out! You're correct that we do need to register Extended sRGB and assign it as the output color space, though.

Conceptually, you can think of Extended sRGB as a color space that pre-supposes some fixed representation of "white" relative to the display, and then provides a mechanism for output at greater wattage than this "white". There are technical/conceptual/perceptual issues with that approach, but it's what WebGPU HDR gives us today, so we'll use it as the output space.

In our working color space, prior to image formation (exposure, tone mapping), we can think of the RGB values as stimulus received by the camera. There's no image yet formed, nothing is relative to any display or viewing environment, and without such a reference to anchor perceptual interpretation there is no "white". So Extended sRGB has no practical meaning as a working color space in a lit rendering pipeline.

Additionally, Extended sRGB uses a non-linear transfer encoding that isn't valid for the PBR rendering process.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I see, thanks for the great explanations @donmccurdy!


// Renderer (HalfFloat output + Extended sRGB)
const renderer = new THREE.WebGPURenderer( {
antialias: true,
transparent: true,
outputType: THREE.HalfFloatType,
} );

renderer.outputColorSpace = ExtendedSRGBColorSpace;
renderer.setPixelRatio( window.devicePixelRatio );
renderer.setSize( window.innerWidth, window.innerHeight );
document.body.appendChild( renderer.domElement );

// 2D orthographic camera (pixel space: [0..W]x[0..H])
const camera = new THREE.OrthographicCamera( 0, window.innerWidth, window.innerHeight, 0, - 1, 1 );


// Brush scene (rendered into drawTarget)
const brushScene = new THREE.Scene();

brushScene.background = new THREE.Color( 0xffffff );
const brushMat = new THREE.MeshBasicNodeMaterial();
brushMat.transparent = true;
brushMat.depthTest = false;
brushMat.depthWrite = false;
brushMat.blending = THREE.AdditiveBlending; // additive to build HDR energy

const postProcessing = new THREE.PostProcessing( renderer );
const brushPass = pass( brushScene, camera, { type: THREE.HalfFloatType } );
brushPass.renderTarget.texture.colorSpace = ExtendedSRGBColorSpace;

postProcessing.outputNode = afterImage( brushPass, params.afterImageDecay );

// HDR brush uniforms
const uColor = params.intensity;
const uHard = params.hardness;
const uRadius = params.radius;

// Radial falloff in TSL
const d = uv().sub( 0.5 ).length();
const t = d.div( uRadius );
const a = t.clamp().oneMinus().pow( uHard.mul( 8.0 ).add( 1.0 ) );

brushMat.colorNode = uColor.mul( a );
brushMat.opacityNode = a; // premultiplied style with additive blending

const brushMesh = new THREE.Mesh( new THREE.PlaneGeometry( 1, 1 ), brushMat );
brushMesh.scale.set( 300, 300, 1 ); // ~300px default brush size
brushScene.add( brushMesh );

function onPointerMove( e ) {

const rect = renderer.domElement.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;

// camera has origin at bottom-left (0,0)
brushMesh.position.set( x, window.innerHeight - y, 0 );

}

window.addEventListener( 'pointermove', onPointerMove, { passive: false } );

// Prevent mobile scroll on touch
renderer.domElement.addEventListener( 'touchstart', ( e ) => e.preventDefault(), { passive: false } );
renderer.domElement.addEventListener( 'touchmove', ( e ) => e.preventDefault(), { passive: false } );
renderer.domElement.addEventListener( 'touchend', ( e ) => e.preventDefault(), { passive: false } );

// GUI setup
const gui = new GUI();

const colorFolder = gui.addFolder( 'HDR' );
colorFolder.add( params.intensity, 'value', 0, 10, 0.1 ).name( 'Intensity' );
colorFolder.open();

const brushFolder = gui.addFolder( 'Brush Settings' );
brushFolder.add( params.hardness, 'value', 0, 0.99, 0.01 ).name( 'Hardness' );
brushFolder.add( params.radius, 'value', 0.1, 2.0, 0.01 ).name( 'Radius' );
brushFolder.open();

const effectFolder = gui.addFolder( 'Effects' );
effectFolder
.add( params.afterImageDecay, 'value', 0.9, 0.999, 0.001 )
.name( 'After Image Decay' );
effectFolder.open();

gui.open();

// Resize handling
function onResize() {

renderer.setSize( window.innerWidth, window.innerHeight );
camera.right = window.innerWidth;
camera.top = window.innerHeight;
camera.updateProjectionMatrix();


}

window.addEventListener( 'resize', onResize );

// Main loop
renderer.setAnimationLoop( async () => {

postProcessing.render();

} );

</script>
</body>
</html>
Loading