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.
// 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:
| Parameter | Type | Default | Description |
|---|---|---|---|
enable | boolean | false | Whether the effect is on; set automatically when ColorGradingPost is added / removed |
exposure | number | 0 | Exposure trim in stops (EV): +1 doubles the brightness, -1 halves it |
temperature | number | 0 | White balance temperature, -1 (cool / blue) ~ +1 (warm / orange) |
tint | number | 0 | White balance tint, -1 (green) ~ +1 (magenta) |
contrast | number | 1 | Contrast around mid-grey, adjusted in gamma space; 1 is unchanged |
saturation | number | 1 | Saturation; 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) |
lutIntensity | number | 1 | Blend 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:
// 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 andsizehigh, for example256 × 16or1024 × 32; - hold
sizesquares side by side, one per blue value; inside each square red runs horizontally and green vertically, with green0on 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.
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.
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();
