LUT Grading¶
3D-LUT colour grading via WorldEnvironment.
▶ Run in browserTags: 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())