色调映射 - Tonemap
引擎内部以线性 HDR 颜色进行光照与后处理计算,像素亮度可以远大于 1,而屏幕只能显示 0 ~ 1 的范围。色调映射(Tone Mapping)用一条曲线把 HDR 颜色压缩到可显示范围,决定了高光如何过渡、颜色如何饱和,是画面整体观感的基础。
色调映射由引擎自动添加的 TonemapPost 完成,无需手动 addPost。它总是在所有其他后处理之后执行(调色 除外),保证泛光、雾效等效果都在 HDR 空间中合成后才经过曲线。通过 engine.setting.render.tonemap 配置:
ts
// 初始化时配置
let engine = await Engine3D.init({
setting: {
render: {
tonemap: {
enable: true,
mode: 'AgX',
exposure: 1.2
}
}
}
});
// 运行时修改,下一帧生效
engine.setting.render.tonemap.mode = 'Neutral';
engine.setting.render.tonemap.exposure = 0.8;engine.setting.render.tonemap 配置参数:
| 参数 | 类型 | 默认值 | 描述 |
|---|---|---|---|
enable | boolean | true | 是否启用。运行时设为 false 时直接输出 HDR 颜色(超出范围的部分在输出时被截断),便于对比;初始化时设为 false 则不会添加 TonemapPost |
mode | TonemapMode | 'ACES' | 色调映射曲线,见下表 |
exposure | number | 1.0 | 曝光,在曲线之前与 HDR 颜色相乘的线性系数 |
whitePoint | number | 4.0 | 仅 'Reinhard' 模式使用:映射为纯白的场景线性亮度 |
可选的曲线 TonemapMode:
| 取值 | 说明 |
|---|---|
'ACES' | 默认。ACES Filmic 近似曲线,对比强、颜色饱和,高光较晚才偏向白色 |
'AgX' | Blender 4 / three.js 使用的 AgX(基础风格),明亮的饱和色会平滑地过渡到白色而不是色相偏移,适合强光源与自发光较多的场景 |
'Neutral' | Khronos PBR Neutral,亮度约 0.76 以下的颜色保持不变,只压缩高光,适合产品展示、电商等需要颜色与原始 sRGB 值一致的场景 |
'Reinhard' | 带白点的扩展 Reinhard 曲线,过渡柔和、对比较低,白点由 whitePoint 控制 |
'Uncharted2' | Hable Filmic(Uncharted 2)曲线,电影感的高光肩部,中间调偏暗 |
'None' | 不使用曲线(exposure 仍然生效),超出 0 ~ 1 的颜色直接被截断 |
TonemapUtil 提供与着色器一致的 CPU 实现,可以用来预估某个 HDR 颜色经过曲线后的结果:
ts
import { TonemapUtil } from '@orillusion/core';
TonemapUtil.MODES; // ['None', 'ACES', 'Reinhard', 'Uncharted2', 'AgX', 'Neutral']
// 参数:曲线、线性 HDR 颜色、曝光、白点
const ldr = TonemapUtil.apply('AgX', [4, 1, 0.25], 1.0, 4.0);下面的示例中,每一行自发光小球的亮度从 0.25 倍递增到 32 倍。切换不同曲线时,注意观察每行最亮的几个小球:None 直接截断成纯色,ACES 保持饱和,AgX 逐渐过渡为白色,Neutral 下方的普通材质颜色几乎不变。
ts
import { AtmosphericComponent, BoxGeometry, CameraUtil, Color, DirectLight, Engine3D, HoverCameraController, KelvinUtil, LitMaterial, MeshRenderer, Object3D, Scene3D, SphereGeometry, TonemapUtil, Vector3, View3D } from '@orillusion/core';
import * as dat from 'dat.gui';
/**
* Tone-mapping comparison: six rows of emissive spheres (red, orange, green, cyan, blue, white),
* each ramping from 0.25x to 32x intensity, above a strip of lit PBR boxes.
* Switch the curve and watch the bright end of every row.
*/
class Sample_Tonemap {
private engine: Engine3D;
private scene: Scene3D;
private readonly probe = { input: '(4.00, 1.00, 0.25)', output: '' };
async run() {
this.engine = await Engine3D.init();
this.scene = new Scene3D();
this.scene.addComponent(AtmosphericComponent).sunY = 0.62;
const camera = CameraUtil.createCamera3DObject(this.scene);
camera.perspective(50, this.engine.aspect, 0.1, 2000);
const ctrl = camera.object3D.addComponent(HoverCameraController);
// Look at the middle of the sphere grid (rows from y = 2 to 17)
ctrl.setCamera(0, -10, 48, new Vector3(0, 8, 0));
const lightObj = new Object3D();
lightObj.rotationX = 45;
lightObj.rotationY = 120;
const light = lightObj.addComponent(DirectLight);
light.lightColor = KelvinUtil.color_temperature_to_rgb(5500);
light.intensity = 3;
this.scene.addChild(lightObj);
this.buildRamp();
const view = new View3D();
view.scene = this.scene;
view.camera = camera;
this.engine.startRenderView(view);
this.initGUI();
}
private buildRamp() {
const hues: [number, number, number][] = [
[1, 0.05, 0.05],
[1, 0.45, 0.05],
[0.1, 1, 0.1],
[0.05, 0.9, 0.9],
[0.1, 0.2, 1],
[1, 1, 1]
];
// 0.25, 0.5, 1, 2, 4, 8, 16, 32
const steps = 8;
const geo = new SphereGeometry(1.1, 32, 32);
for (let row = 0; row < hues.length; row++) {
for (let i = 0; i < steps; i++) {
const mat = new LitMaterial();
mat.baseColor = new Color(0, 0, 0, 1);
mat.emissiveColor = new Color(hues[row][0], hues[row][1], hues[row][2], 1);
mat.emissiveIntensity = 0.25 * Math.pow(2, i);
mat.roughness = 1;
const obj = new Object3D();
const mr = obj.addComponent(MeshRenderer);
mr.geometry = geo;
mr.material = mat;
obj.x = (i - (steps - 1) / 2) * 3;
obj.y = (hues.length - 1 - row) * 3 + 2;
this.scene.addChild(obj);
}
}
// Lit strip: how each curve treats ordinary diffuse / specular shading
const colors = [new Color(0.8, 0.1, 0.1), new Color(0.9, 0.6, 0.1), new Color(0.1, 0.6, 0.2), new Color(0.1, 0.3, 0.8), new Color(0.8, 0.8, 0.8)];
const box = new BoxGeometry(4.4, 1, 6);
colors.forEach((c, i) => {
const mat = new LitMaterial();
mat.baseColor = c;
mat.roughness = 0.35;
mat.metallic = 0.1;
const obj = new Object3D();
const mr = obj.addComponent(MeshRenderer);
mr.geometry = box;
mr.material = mat;
obj.x = (i - 2) * 4.8;
obj.y = -0.5;
this.scene.addChild(obj);
});
}
private initGUI() {
// Tonemap is attached by the engine; it reads setting.render.tonemap every frame
const tm = this.engine.setting.render.tonemap;
// TonemapUtil.apply is the CPU mirror of the shader: show what the curve does to one HDR color
const refresh = () => {
const o = TonemapUtil.apply(tm.mode, [4, 1, 0.25], tm.exposure, tm.whitePoint ?? 4);
this.probe.output = `(${o.map((v) => v.toFixed(3)).join(', ')})`;
};
const gui = new dat.GUI();
const f = gui.addFolder('Tonemap');
f.add(tm, 'mode', TonemapUtil.MODES).onChange(refresh);
f.add(tm, 'exposure', 0.05, 8, 0.01).onChange(refresh);
f.add(tm, 'whitePoint', 1, 16, 0.1).onChange(refresh);
f.add(tm, 'enable');
f.add(this.probe, 'input');
f.add(this.probe, 'output').listen();
f.open();
// Start collapsed so the panel does not cover the scene
gui.close();
refresh();
}
}
new Sample_Tonemap().run();
