LUT Grading

3D-LUT colour grading via WorldEnvironment.

▶ Run in browser

Tags: 3d post-process colour-grading lut tonemap

Colour grading gives a whole frame a look, warm or cold, faded or punchy, in one step at the end of rendering. A 3D lookup table (LUT) is a small cube of colours: every final colour on screen is used as a coordinate into the cube and replaced by what is stored there. The renderer samples it after tonemapping, on the finished display colour, identically on the desktop and in the browser.

simvx.core.colour_grading builds LUTs as (size, size, size, 4) uint8 arrays (generate_warm_lut, generate_cool_lut, generate_vintage_lut, and generate_neutral_lut as a starting point for your own). Register each one with the running app under an integer id of your choosing, then pick one on the WorldEnvironment:

self.app.register_lut(1, generate_warm_lut(32))
env.lut_tex_id = 1
env.lut_enabled = True

lut_enabled = False turns grading off and shows the image as tonemapped. The scene starts on the warm grade so the effect shows at once; the buttons, or the keys 1, 2 and 3, switch between no grade, warm and cool on a field of neutral grey objects where the shift is easy to see.

Controls: 1 / 2 / 3 - No grade / warm / cool (or the buttons) Drag / WASD / right stick - Orbit the camera Scroll / pinch / triggers - Zoom

Source

  1# /// simvx
  2# tags = ["post-process", "colour-grading", "lut", "tonemap"]
  3# web = { width = 1280, height = 720 }
  4# ///
  5"""LUT Grading: 3D-LUT colour grading via WorldEnvironment.
  6
  7Colour grading gives a whole frame a look, warm or cold, faded or punchy, in one
  8step at the end of rendering. A 3D lookup table (LUT) is a small cube of
  9colours: every final colour on screen is used as a coordinate into the cube and
 10replaced by what is stored there. The renderer samples it after tonemapping, on
 11the finished display colour, identically on the desktop and in the browser.
 12
 13``simvx.core.colour_grading`` builds LUTs as ``(size, size, size, 4)`` uint8
 14arrays (``generate_warm_lut``, ``generate_cool_lut``, ``generate_vintage_lut``,
 15and ``generate_neutral_lut`` as a starting point for your own). Register each
 16one with the running app under an integer id of your choosing, then pick one on
 17the ``WorldEnvironment``:
 18
 19    self.app.register_lut(1, generate_warm_lut(32))
 20    env.lut_tex_id = 1
 21    env.lut_enabled = True
 22
 23``lut_enabled = False`` turns grading off and shows the image as tonemapped.
 24The scene starts on the warm grade so the effect shows at once; the buttons, or
 25the keys 1, 2 and 3, switch between no grade, warm and cool on a field of
 26neutral grey objects where the shift is easy to see.
 27
 28Controls:
 29    1 / 2 / 3                 - No grade / warm / cool (or the buttons)
 30    Drag / WASD / right stick - Orbit the camera
 31    Scroll / pinch / triggers - Zoom
 32"""
 33
 34import math
 35
 36import numpy as np
 37
 38from simvx.core import (
 39    Anchor,
 40    Axis2D,
 41    Button,
 42    CanvasLayer,
 43    DirectionalLight3D,
 44    HBoxContainer,
 45    Input,
 46    Key,
 47    Label,
 48    Material,
 49    Mesh,
 50    MeshInstance3D,
 51    Node,
 52    OrbitCamera3D,
 53    Vec3,
 54    WorldEnvironment,
 55)
 56from simvx.core.colour_grading import generate_cool_lut, generate_warm_lut
 57from simvx.graphics import App
 58
 59# The id each grade's LUT is registered under; 0 stands for "no grade".
 60GRADES = {"none": 0, "warm": 1, "cool": 2}
 61ORBIT_SPEED = math.radians(60)  # radians per second at full key deflection
 62GREYS = [(0.85, 0.85, 0.85), (0.8, 0.8, 0.82), (0.75, 0.78, 0.85), (0.82, 0.8, 0.78), (0.78, 0.82, 0.8)]
 63
 64
 65class LutGrading(Node):
 66    input_context = {"none": [Key.KEY_1], "warm": [Key.KEY_2], "cool": [Key.KEY_3], "orbit": [Axis2D.WASD]}
 67
 68    def on_ready(self):
 69        self.app.register_lut(GRADES["warm"], generate_warm_lut(32))
 70        self.app.register_lut(GRADES["cool"], generate_cool_lut(32))
 71        self.env = self.add_child(WorldEnvironment())
 72
 73        self.camera = self.add_child(
 74            OrbitCamera3D(
 75                pivot=Vec3(0, 1, 0),
 76                distance=16.0,
 77                yaw=math.radians(35),
 78                pitch=math.radians(-22),
 79                min_pitch=math.radians(-80),
 80                max_pitch=math.radians(10),
 81                min_distance=6.0,
 82                max_distance=40.0,
 83                fov=55,
 84                far=200.0,
 85            )
 86        )
 87        self.add_child(DirectionalLight3D(intensity=2.2, direction=(-0.6, -1.0, -0.4)))
 88        self.add_child(DirectionalLight3D(intensity=0.3, colour=(0.7, 0.8, 1.0), direction=(0.8, -0.6, 1.0)))
 89        self.add_child(MeshInstance3D(mesh=Mesh.plane(40), material=Material(colour=(0.6, 0.6, 0.62), roughness=0.85)))
 90
 91        # Three loose rings of near-neutral boxes and spheres, so the grade is the only colour in the frame.
 92        rng = np.random.default_rng(3)
 93        for i in range(20):
 94            ring = 3.0 + (i % 3) * 2.5
 95            angle = i * math.tau / 7
 96            x, z = math.cos(angle) * ring + rng.uniform(-0.4, 0.4), math.sin(angle) * ring + rng.uniform(-0.4, 0.4)
 97            self.add_child(
 98                MeshInstance3D(
 99                    mesh=Mesh.box() if i % 2 == 0 else Mesh.sphere(radius=0.6),
100                    material=Material(colour=GREYS[i % len(GREYS)], roughness=0.6, metallic=0.05),
101                    position=(x, 0.6 + (i % 3) * 0.3, z),
102                )
103            )
104
105        hud = self.add_child(CanvasLayer())
106        bar = hud.add_child(HBoxContainer(anchor=Anchor.BOTTOM_LEFT, inset=12))
107        self.buttons = {grade: bar.add_child(Button(grade.title(), action=grade)) for grade in GRADES}
108        self.set_grade("warm")
109
110        # Example help overlay: not part of the technique shown.
111        hud.add_child(Label("Drag / WASD: orbit   Scroll: zoom", anchor=Anchor.TOP_LEFT, inset=12))
112
113    def set_grade(self, grade: str):
114        self.env.lut_tex_id = GRADES[grade]
115        self.env.lut_enabled = GRADES[grade] != 0
116        for name, button in self.buttons.items():
117            button.checked = name == grade
118
119    def on_update(self, dt):
120        keys = Input.value("orbit")
121        self.camera.orbit(-keys.x * ORBIT_SPEED * dt, keys.y * ORBIT_SPEED * dt)
122        for grade in GRADES:
123            if Input.just_pressed(grade):
124                self.set_grade(grade)
125
126
127if __name__ == "__main__":
128    App(title="LUT Grading", width=1280, height=720).run(LutGrading())