hdr.ts
src/gpu/hdr.ts · raw · all examples · site home
Exports: HdrToneMappinghdrToneMappingActiveHdr
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177
// HDR output. Two independent pieces:
//
// - The HDR canvas (WebGPU backend only): createRenderer({ hdr }) configures
// the canvas as rgba16float with extended tone mapping, so values above 1
// show brighter than SDR white on HDR displays (Chrome 129+). The canvas
// stays "srgb": its values are sRGB-encoded, so colors drawn straight to
// it blend as they would on an SDR canvas. Hdr.setOutput() turns the
// extended range off and on without recreating anything.
//
// - Headroom tone mapping (both backends): Hdr.useToneMapping() makes three
// render into its float framebuffer target and compresses the finished
// frame into [0, headroom] in its output pass: untouched below the knee, a
// smooth shoulder above it, and colors past the headroom fading to white
// instead of clipping per channel. headroom 1 is SDR tone mapping. The
// target holds what the canvas would without it (splats blend
// sRGB-encoded colors, as they were trained), so the pass decodes sRGB
// first and three encodes the result: below the knee the frame is the
// one without tone mapping.
//
// There is no web API for the display's actual headroom yet; pick one (about
// 2 to 8 on current HDR laptops and monitors) or expose it as a setting.
import * as THREE from "three";
import type { WebGPURenderer } from "three/webgpu";
import { backendOf } from "./backend";
import { TSL, type TslNode } from "./tsl";
const { Fn, clamp, exp, float, max, mix, select, sRGBTransferEOTF, uniform, vec3 } = TSL;
// Whether the display reports high dynamic range (the browser also needs to)
function displaySupportsHdr(): boolean {
return typeof window !== "undefined" && window.matchMedia?.("(dynamic-range: high)").matches === true;
}
interface HdrBackend {
isWebGPUBackend?: boolean;
device?: GPUDevice;
context?: GPUCanvasContext;
parameters: { outputType?: number; alpha?: boolean };
}
// The canvas can show the extended range: WebGPU with an rgba16float canvas
// (createRenderer({ hdr })) and a browser that kept the extended mode
function canvasSupportsHdr(renderer: WebGPURenderer): boolean {
const backend = backendOf(renderer) as unknown as HdrBackend;
if (!backend.isWebGPUBackend || backend.parameters.outputType !== THREE.HalfFloatType) {
return false;
}
// Browsers without canvas tone mapping drop the member from the configuration
const configuration = (backend.context as unknown as { getConfiguration?(): GPUCanvasConfiguration | null } | undefined)?.getConfiguration?.();
return !configuration || (configuration as { toneMapping?: { mode?: string } }).toneMapping !== undefined;
}
// Extended (true) or standard (false) range on an HDR-capable canvas; false
// if the canvas cannot (see Hdr.canvasSupports)
function setHdrOutput(renderer: WebGPURenderer, extended: boolean): boolean {
if (!canvasSupportsHdr(renderer)) {
return false;
}
const backend = backendOf(renderer) as unknown as HdrBackend;
backend.context!.configure({
device: backend.device!,
format: "rgba16float",
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
alphaMode: backend.parameters.alpha ? "premultiplied" : "opaque",
toneMapping: { mode: extended ? "extended" : "standard" },
} as GPUCanvasConfiguration);
return true;
}
export interface HdrToneMapping {
// Linear brightness the output may reach: 1 for SDR, the display's HDR
// headroom otherwise (uniform nodes: set .value)
headroom: TslNode;
// Fraction of the headroom kept untouched before the shoulder
knee: TslNode;
// How fast colors past the headroom fade to white (0: never)
whiten: TslNode;
// Exposure multiplier before tone mapping (renderer.toneMappingExposure)
get exposure(): number;
set exposure(value: number);
get enabled(): boolean;
set enabled(on: boolean);
}
const toneMappings = new WeakMap<WebGPURenderer, HdrToneMapping>();
// Headroom tone mapping for `renderer` (one per renderer; later calls return
// it with the options applied). Enabling it sets toneMapping and an sRGB
// output color space; disabling restores what was there.
function useHdrToneMapping(renderer: WebGPURenderer, {
headroom,
knee,
whiten,
enabled = true,
}: { headroom?: number; knee?: number; whiten?: number; enabled?: boolean } = {}): HdrToneMapping {
let toneMapping = toneMappings.get(renderer);
if (!toneMapping) {
const u = { headroom: uniform(1), knee: uniform(0.75), whiten: uniform(0.5) };
renderer.library.addToneMapping(Fn(([color, exposure]: [TslNode, TslNode]) => {
const c = sRGBTransferEOTF(max(color, vec3(0.0))).mul(exposure).toVar();
const peak = max(c.r, max(c.g, c.b)).toVar();
const limit = max(u.headroom, 1e-3).toVar();
const start = limit.mul(clamp(u.knee, 0.0, 0.99)).toVar();
const range = limit.sub(start).toVar();
// Identity up to the knee, then an exponential shoulder toward the
// limit (continuous in value and slope); hue kept by scaling all channels
const mapped = select(peak.lessThanEqual(start), peak, start.add(range.mul(float(1).sub(exp(peak.sub(start).div(range).negate()))))).toVar();
const scaled = c.mul(mapped.div(max(peak, 1e-6)));
const past = max(peak.sub(limit), 0.0).div(limit);
return mix(scaled, vec3(mapped), float(1).sub(exp(past.mul(u.whiten).negate())));
}), THREE.CustomToneMapping);
const saved = { toneMapping: renderer.toneMapping, outputColorSpace: renderer.outputColorSpace };
let on = false;
toneMapping = {
...u,
get exposure() {
return renderer.toneMappingExposure;
},
set exposure(value: number) {
renderer.toneMappingExposure = value;
},
get enabled() {
return on;
},
set enabled(value: boolean) {
if (value === on) {
return;
}
on = value;
if (on) {
saved.toneMapping = renderer.toneMapping;
saved.outputColorSpace = renderer.outputColorSpace;
renderer.toneMapping = THREE.CustomToneMapping;
renderer.outputColorSpace = THREE.SRGBColorSpace;
} else {
renderer.toneMapping = saved.toneMapping;
renderer.outputColorSpace = saved.outputColorSpace;
}
},
};
toneMapping.headroom.value = canvasSupportsHdr(renderer) ? 4 : 1;
toneMappings.set(renderer, toneMapping);
}
if (headroom !== undefined) {
toneMapping.headroom.value = headroom;
}
if (knee !== undefined) {
toneMapping.knee.value = knee;
}
if (whiten !== undefined) {
toneMapping.whiten.value = whiten;
}
toneMapping.enabled = enabled;
return toneMapping;
}
// Whether `renderer` renders through Hdr.useToneMapping's pass now (its
// frame target then holds sRGB-encoded colors)
export function hdrToneMappingActive(renderer: WebGPURenderer): boolean {
return toneMappings.get(renderer)?.enabled === true && renderer.toneMapping === THREE.CustomToneMapping;
}
export class Hdr {
// Whether the display reports high dynamic range (the browser also needs to)
static readonly displaySupports = displaySupportsHdr;
// The canvas can show the extended range: WebGPU with an rgba16float
// canvas (createRenderer({ hdr })) and a browser that kept the extended mode
static readonly canvasSupports = canvasSupportsHdr;
// Extended (true) or standard (false) range on an HDR-capable canvas;
// false if the canvas cannot
static readonly setOutput = setHdrOutput;
// Headroom tone mapping for a renderer (one per renderer; later calls
// return it with the options applied)
static readonly useToneMapping = useHdrToneMapping;
}