Skip to content

调色 - ColorGradingPost ​

调色(Color Grading)用于统一画面风格,例如冷暖色调、电影感的青橙色调、复古的褐色调等。ColorGradingPost 在 色调映射 之后、对已映射到 0 ~ 1 的显示颜色进行调整,与添加顺序无关,因此调色参数不会受 HDR 亮度和色调映射曲线的影响。

ts
// 初始化引擎,可在此配置默认参数
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 配置参数:

参数类型默认值描述
enablebooleanfalse是否启用,添加 / 移除 ColorGradingPost 时自动设置
exposurenumber0曝光微调,单位为档(EV):+1 亮度加倍,-1 减半
temperaturenumber0白平衡色温,-1(偏冷 / 蓝)~ +1(偏暖 / 橙)
tintnumber0白平衡色调,-1(偏绿)~ +1(偏品红)
contrastnumber1对比度,以中灰为中心在 gamma 空间中调整,1 不变
saturationnumber1饱和度,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 幂(伽马)
lutIntensitynumber1LUT 结果与调色结果的混合比例,0 ~ 1;未设置 LUT 时无效

TIP

exposure 以档为单位,与 色调映射 中作用在曲线之前的线性 exposure 不同:这里是对最终显示颜色的微调。

LUT ​

ASC-CDL 是行业通用的调色描述方式:out = (in × slope + offset) ^ power,三个参数都可以按 RGB 通道分别设置,能够表达大部分一级调色。更复杂的风格可以使用 LUT(颜色查找表),它把每一种输入颜色映射到一个输出颜色,可以由调色软件导出,也可以用代码生成。

用函数生成 LUT ​

setLUTFromFunction(size, fn) 用一个函数生成 LUT 并立即使用。函数的输入输出都是 0 ~ 1 的 sRGB 编码颜色:

ts
// 复古褐色调:按亮度重新着色
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,让着色器读到原始值。
ts
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 调节它与原画面的混合比例。

WebGPU is not supported in your browser
Please upgrade to latest Chrome/Edge

<
ts
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();