调色 - ColorGradingPost
调色(Color Grading)用于统一画面风格,例如冷暖色调、电影感的青橙色调、复古的褐色调等。ColorGradingPost 在 色调映射 之后、对已映射到 0 ~ 1 的显示颜色进行调整,与添加顺序无关,因此调色参数不会受 HDR 亮度和色调映射曲线的影响。
// 初始化引擎,可在此配置默认参数
let engine = await Engine3D.init({
setting: {
render: {
postProcessing: {
colorGrading: {
contrast: 1.15,
saturation: 1.1,
temperature: 0.2
}
}
}
}
});
// 添加后处理组件
let postProcessing = scene.addComponent(PostProcessingComponent);
// 添加 ColorGradingPost
let grade = postProcessing.addPost(ColorGradingPost);
// 也可以通过 grade 对象设置,与上面的引擎配置等价
grade.contrast = 1.15;
grade.saturation = 1.1;
grade.temperature = 0.2;
// ASC-CDL 按通道设置 [r, g, b]
grade.slope = [1.05, 1.0, 0.95];各项调整按以下顺序执行:曝光与白平衡 → 对比度 → 饱和度 → ASC-CDL → LUT。
engine.setting.render.postProcessing.colorGrading 配置参数:
| 参数 | 类型 | 默认值 | 描述 |
|---|---|---|---|
enable | boolean | false | 是否启用,添加 / 移除 ColorGradingPost 时自动设置 |
exposure | number | 0 | 曝光微调,单位为档(EV):+1 亮度加倍,-1 减半 |
temperature | number | 0 | 白平衡色温,-1(偏冷 / 蓝)~ +1(偏暖 / 橙) |
tint | number | 0 | 白平衡色调,-1(偏绿)~ +1(偏品红) |
contrast | number | 1 | 对比度,以中灰为中心在 gamma 空间中调整,1 不变 |
saturation | number | 1 | 饱和度,0 为灰度,1 不变,大于 1 更鲜艳 |
slope | [r, g, b] | [1, 1, 1] | ASC-CDL 斜率(增益),与颜色相乘 |
offset | [r, g, b] | [0, 0, 0] | ASC-CDL 偏移(提升),与颜色相加 |
power | [r, g, b] | [1, 1, 1] | ASC-CDL 幂(伽马) |
lutIntensity | number | 1 | LUT 结果与调色结果的混合比例,0 ~ 1;未设置 LUT 时无效 |
TIP
exposure 以档为单位,与 色调映射 中作用在曲线之前的线性 exposure 不同:这里是对最终显示颜色的微调。
LUT
ASC-CDL 是行业通用的调色描述方式:out = (in × slope + offset) ^ power,三个参数都可以按 RGB 通道分别设置,能够表达大部分一级调色。更复杂的风格可以使用 LUT(颜色查找表),它把每一种输入颜色映射到一个输出颜色,可以由调色软件导出,也可以用代码生成。
用函数生成 LUT
setLUTFromFunction(size, fn) 用一个函数生成 LUT 并立即使用。函数的输入输出都是 0 ~ 1 的 sRGB 编码颜色:
// 复古褐色调:按亮度重新着色
grade.setLUTFromFunction(32, (r, g, b) => {
const l = 0.2126 * r + 0.7152 * g + 0.0722 * b;
return [l * 1.07 + 0.03, l * 0.92 + 0.02, l * 0.72];
});
grade.lutIntensity = 0.8;
// 移除 LUT
grade.lut = null;size 为 LUT 立方体的边长,32 已能满足大多数风格,更平滑的渐变可以用更大的值。
使用 LUT 图片
也可以把调色软件导出的条带状 LUT 图片赋给 lut。图片需满足:
- 宽为
size²、高为size,例如256 × 16、1024 × 32; - 横向依次排列
size个方块,每个方块对应一个蓝色分量;方块内横向为红色、纵向为绿色,最上面一行绿色为0; - 存储 sRGB 编码的颜色值,加载时使用默认的线性颜色空间、关闭 mipmap,让着色器读到原始值。
import { BitmapTexture2D } from '@orillusion/core';
// 第一个参数 false:不生成 mipmap
const lutTexture = new BitmapTexture2D(false);
await lutTexture.load('images/lut_night.png');
grade.lut = lutTexture;下面的示例包含一面色卡(12 种色相 × 4 种明度,顶部为灰阶)和一排不同材质的小球,便于观察每项调整的效果;look 菜单提供几种用代码生成的 LUT 风格,其中 night (image) 加载的是一张 1024 × 32 的 LUT 图片(夜景色调)。选择一种 LUT 后,可以用 lutIntensity 调节它与原画面的混合比例。
import { AtmosphericComponent, BitmapTexture2D, BoxGeometry, CameraUtil, Color, ColorGradingPost, DirectLight, Engine3D, GeometryBase, HoverCameraController, KelvinUtil, LitMaterial, MeshRenderer, Object3D, PostProcessingComponent, Scene3D, SphereGeometry, Vector3, View3D } from '@orillusion/core';
import * as dat from 'dat.gui';
/**
* ColorGradingPost: a color chart (hue x lightness, plus a grey ramp) and a row of materials make
* every adjustment easy to read. The "look" menu applies LUTs generated in code, or a strip LUT image.
*/
class Sample_ColorGrading {
private scene: Scene3D;
private grade: ColorGradingPost;
async run() {
const engine = await Engine3D.init({ setting: { shadow: { enable: true, shadowBound: 60 } } });
this.scene = new Scene3D();
this.scene.addComponent(AtmosphericComponent).sunY = 0.62;
const camera = CameraUtil.createCamera3DObject(this.scene);
camera.perspective(45, engine.aspect, 0.1, 1000);
const ctrl = camera.object3D.addComponent(HoverCameraController);
// Look at the middle of the color chart
ctrl.setCamera(0, -8, 36, new Vector3(0, 5, 0));
const lightObj = new Object3D();
lightObj.rotationX = 40;
lightObj.rotationY = 150;
const light = lightObj.addComponent(DirectLight);
light.lightColor = KelvinUtil.color_temperature_to_rgb(5600);
light.intensity = 3;
light.castShadow = true;
light.enableCSM = true;
this.scene.addChild(lightObj);
this.build();
const view = new View3D();
view.scene = this.scene;
view.camera = camera;
engine.startRenderView(view);
const post = this.scene.addComponent(PostProcessingComponent);
this.grade = post.addPost(ColorGradingPost);
this.initGUI();
}
private hsl(h: number, s: number, l: number): Color {
const a = s * Math.min(l, 1 - l);
const f = (n: number) => {
const k = (n + h * 12) % 12;
return l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1));
};
return new Color(f(0), f(8), f(4), 1);
}
private add(geo: GeometryBase, mat: LitMaterial, x: number, y: number, z = 0) {
const obj = new Object3D();
const mr = obj.addComponent(MeshRenderer);
mr.geometry = geo;
mr.material = mat;
mr.castShadow = true;
obj.x = x;
obj.y = y;
obj.z = z;
this.scene.addChild(obj);
}
private build() {
// Color chart: 12 hues x 4 lightness rows, and a grey ramp on top
const chip = new BoxGeometry(1.8, 1.8, 0.3);
for (let hx = 0; hx < 12; hx++) {
for (let ly = 0; ly < 5; ly++) {
const m = new LitMaterial();
m.baseColor = ly === 4 ? new Color(hx / 11, hx / 11, hx / 11, 1) : this.hsl(hx / 12, 0.85, 0.2 + ly * 0.18);
m.roughness = 0.8;
m.metallic = 0;
this.add(chip, m, (hx - 5.5) * 2, 3 + ly * 2, -3);
}
}
// Materials: [r, g, b, roughness, metallic]
const sphere = new SphereGeometry(1.6, 40, 40);
const mats: [number, number, number, number, number][] = [
[0.9, 0.2, 0.15, 0.4, 0],
[0.95, 0.75, 0.3, 0.3, 1],
[0.2, 0.6, 0.3, 0.6, 0],
[0.2, 0.4, 0.9, 0.2, 0],
[0.9, 0.9, 0.9, 0.15, 1]
];
mats.forEach((c, i) => {
const m = new LitMaterial();
m.baseColor = new Color(c[0], c[1], c[2], 1);
m.roughness = c[3];
m.metallic = c[4];
this.add(sphere, m, (i - 2) * 4.2, 1.6, 3);
});
const floorMat = new LitMaterial();
floorMat.baseColor = new Color(0.5, 0.5, 0.5);
floorMat.roughness = 0.9;
floorMat.metallic = 0;
const floor = new Object3D();
const fm = floor.addComponent(MeshRenderer);
fm.geometry = new BoxGeometry(120, 1, 80);
fm.material = floorMat;
fm.castShadow = false;
floor.y = -0.5;
this.scene.addChild(floor);
}
private initGUI() {
const g = this.grade;
const gui = new dat.GUI();
const f = gui.addFolder('ColorGradingPost');
f.add(g, 'exposure', -3, 3, 0.01);
f.add(g, 'temperature', -1, 1, 0.01);
f.add(g, 'tint', -1, 1, 0.01);
f.add(g, 'contrast', 0, 2, 0.01);
f.add(g, 'saturation', 0, 2, 0.01);
// ASC-CDL takes per-channel values; the sliders set all three channels at once
const cdl = { slope: 1, offset: 0, power: 1 };
f.add(cdl, 'slope', 0.2, 2, 0.01).onChange((v: number) => (g.slope = [v, v, v]));
f.add(cdl, 'offset', -0.3, 0.3, 0.005).onChange((v: number) => (g.offset = [v, v, v]));
f.add(cdl, 'power', 0.3, 3, 0.01).onChange((v: number) => (g.power = [v, v, v]));
// LUT looks, generated from functions on sRGB-encoded colors
const lum = (r: number, gg: number, b: number) => 0.2126 * r + 0.7152 * gg + 0.0722 * b;
const looks: Record<string, ((r: number, g: number, b: number) => [number, number, number]) | null> = {
none: null,
'teal-orange': (r, gg, b) => {
const l = lum(r, gg, b);
return [r + 0.12 * (l - 0.4), gg + 0.02, b + 0.14 * (0.5 - l)];
},
sepia: (r, gg, b) => {
const l = lum(r, gg, b);
return [l * 1.07 + 0.03, l * 0.92 + 0.02, l * 0.72];
},
bleach: (r, gg, b) => {
const l = lum(r, gg, b);
const c = (v: number) => (v + (l - v) * 0.6 - 0.5) * 1.35 + 0.5;
return [c(r), c(gg), c(b)];
},
invert: (r, gg, b) => [1 - r, 1 - gg, 1 - b]
};
// A strip LUT image (1024 x 32): no mipmaps, default linear color space so the shader reads the stored values
let nightLUT: BitmapTexture2D | null = null;
const loadNightLUT = async () => {
if (!nightLUT) {
nightLUT = new BitmapTexture2D(false);
// Relative to demo.html, so it works under any site base (/ or /en/)
await nightLUT.load('images/lut_night.png');
}
return nightLUT;
};
f.add({ look: 'none' }, 'look', [...Object.keys(looks), 'night (image)']).onChange(async (v: string) => {
if (v === 'night (image)') {
g.lut = await loadNightLUT();
return;
}
const fn = looks[v];
if (fn) g.setLUTFromFunction(32, fn);
else g.lut = null;
});
// Blends the graded color (0) with the LUT result (1); no effect while look is 'none'
f.add(g, 'lutIntensity', 0, 1, 0.01).name('lutIntensity (LUT)');
f.open();
// Start collapsed so the panel does not cover the scene
gui.close();
}
}
new Sample_ColorGrading().run();
