Skip to content

Color Grading - ColorGradingPost ​

Color grading gives the image a consistent style: warm or cool tones, the cinematic teal-orange look, a vintage sepia and so on. ColorGradingPost runs after tone mapping, regardless of the order it is added in, and adjusts display colors already mapped to 0 ~ 1, so grading is not affected by HDR brightness or the tone curve.

ts
// Initialize the engine; default parameters can be configured here
let engine = await Engine3D.init({
    setting: {
        render: {
            postProcessing: {
                colorGrading: {
                    contrast: 1.15,
                    saturation: 1.1,
                    temperature: 0.2
                }
            }
        }
    }
});

// Add the post-processing component
let postProcessing = scene.addComponent(PostProcessingComponent);

// Add ColorGradingPost
let grade = postProcessing.addPost(ColorGradingPost);

// The grade object can be configured too, equivalent to the engine settings above
grade.contrast = 1.15;
grade.saturation = 1.1;
grade.temperature = 0.2;
// ASC-CDL values are per channel [r, g, b]
grade.slope = [1.05, 1.0, 0.95];

The adjustments are applied in this order: exposure and white balance → contrast → saturation → ASC-CDL → LUT.

engine.setting.render.postProcessing.colorGrading parameters:

ParameterTypeDefaultDescription
enablebooleanfalseWhether the effect is on; set automatically when ColorGradingPost is added / removed
exposurenumber0Exposure trim in stops (EV): +1 doubles the brightness, -1 halves it
temperaturenumber0White balance temperature, -1 (cool / blue) ~ +1 (warm / orange)
tintnumber0White balance tint, -1 (green) ~ +1 (magenta)
contrastnumber1Contrast around mid-grey, adjusted in gamma space; 1 is unchanged
saturationnumber1Saturation; 0 is greyscale, 1 unchanged, above 1 more vivid
slope[r, g, b][1, 1, 1]ASC-CDL slope (gain), multiplied onto the color
offset[r, g, b][0, 0, 0]ASC-CDL offset (lift), added to the color
power[r, g, b][1, 1, 1]ASC-CDL power (gamma)
lutIntensitynumber1Blend between the graded color and the LUT result, 0 ~ 1; no effect without a LUT

TIP

exposure here is in stops and differs from the linear exposure of tone mapping, which applies before the curve: this one is a trim of the final display color.

LUT ​

ASC-CDL is the industry-standard description of a grade, out = (in × slope + offset) ^ power, with every parameter per RGB channel; it covers most primary grading. More elaborate looks use a LUT (lookup table), which maps every input color to an output color. LUTs can be exported from grading software or generated in code.

Generating a LUT from a Function ​

setLUTFromFunction(size, fn) builds a LUT from a function and uses it right away. The function takes and returns sRGB-encoded colors in 0 ~ 1:

ts
// Vintage sepia: recolor by luminance
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;

// Remove the LUT
grade.lut = null;

size is the edge length of the LUT cube; 32 suits most looks, and larger values give smoother gradients.

Using a LUT Image ​

You can also assign a strip LUT image exported from grading software to lut. The image must:

  • be size² wide and size high, for example 256 × 16 or 1024 × 32;
  • hold size squares side by side, one per blue value; inside each square red runs horizontally and green vertically, with green 0 on the top row;
  • store sRGB-encoded values, and be loaded with the default linear color space and without mipmaps, so the shader reads the stored values as they are.
ts
import { BitmapTexture2D } from '@orillusion/core';

// First argument false: no mipmaps
const lutTexture = new BitmapTexture2D(false);
await lutTexture.load('images/lut_night.png');
grade.lut = lutTexture;

The example below contains a color chart (12 hues × 4 lightness levels, with a grey ramp on top) and a row of spheres with different materials, so the effect of each adjustment is easy to read. The look menu offers a few LUT looks generated in code; night (image) loads a 1024 × 32 LUT image (a night look). After choosing a LUT, lutIntensity sets how much of it is blended with the original image.

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

Released under the MIT License