LUT Grading¶
3D-LUT colour grading via WorldEnvironment.
▶ Run in browserTags: 3d post-process colour-grading lut tonemap
Drives WorldEnvironment.lut_enabled / lut_tex_id, which the desktop
Vulkan renderer and the web backend implement identically: a 3D rgba8 LUT
sampled post-tonemap on the final LDR colour. A warm grade (id 1) and a cool/teal grade (id 2) are
registered at startup and the scene boots with the warm grade active; a key
press or an on-screen button swaps the active LUT or disables grading
entirely. The identity (no-op) state is lut_enabled = False.
Controls: A / D - Orbit camera left / right W / S - Pitch camera up / down 1 - No LUT (neutral) 2 - Warm grade 3 - Cool / teal grade Escape - Quit Mouse/touch - None / Warm / Cool buttons switch the grade
Run: uv run python examples/features/3d/lut_grading.py
Source¶
1"""LUT Grading: 3D-LUT colour grading via WorldEnvironment.
2
3# /// simvx
4# tags = ["post-process", "colour-grading", "lut", "tonemap"]
5# web = { width = 1280, height = 720 }
6# ///
7
8Drives ``WorldEnvironment.lut_enabled`` / ``lut_tex_id``, which the desktop
9Vulkan renderer and the web backend implement identically: a 3D ``rgba8`` LUT
10sampled post-tonemap on the final LDR colour. A warm grade (id 1) and a cool/teal grade (id 2) are
11registered at startup and the scene boots with the warm grade active; a key
12press or an on-screen button swaps the active LUT or disables grading
13entirely. The identity (no-op) state is ``lut_enabled = False``.
14
15Controls:
16 A / D - Orbit camera left / right
17 W / S - Pitch camera up / down
18 1 - No LUT (neutral)
19 2 - Warm grade
20 3 - Cool / teal grade
21 Escape - Quit
22 Mouse/touch - None / Warm / Cool buttons switch the grade
23
24Run: uv run python examples/features/3d/lut_grading.py
25"""
26
27import math
28
29import numpy as np
30
31from simvx.core import (
32 AnchorPreset,
33 Button,
34 Camera3D,
35 Colour,
36 DirectionalLight3D,
37 Input,
38 Key,
39 Material,
40 Mesh,
41 MeshInstance3D,
42 Node,
43 Panel,
44 Text2D,
45 Vec2,
46 WorldEnvironment,
47)
48from simvx.core.colour_grading import generate_cool_lut, generate_warm_lut
49from simvx.graphics import App
50
51LUT_WARM = 1
52LUT_COOL = 2
53
54
55class LutGrading(Node):
56 input_actions = {
57 "orbit_left": [Key.A],
58 "orbit_right": [Key.D],
59 "pitch_up": [Key.W],
60 "pitch_down": [Key.S],
61 "lut_off": [Key.KEY_1],
62 "lut_warm": [Key.KEY_2],
63 "lut_cool": [Key.KEY_3],
64 "quit": [Key.ESCAPE],
65 }
66
67 def on_ready(self):
68 self._yaw = 35.0
69 self._pitch = 22.0
70 self._distance = 16.0
71 self._target = (0.0, 1.0, 0.0)
72
73 self._cam = Camera3D(name="Camera", fov=55, near=0.1, far=200.0)
74 self.add_child(self._cam)
75
76 env = self.add_child(WorldEnvironment())
77 env.tonemap_mode = "aces"
78 env.tonemap_white = 1.0
79 env.tonemap_exposure = 1.0
80 env.sky_mode = "colour"
81 self._env = env
82
83 key = DirectionalLight3D(name="KeyLight", intensity=2.2)
84 key.look_at((-0.6, -1.0, -0.4))
85 self.add_child(key)
86 fill = DirectionalLight3D(name="FillLight", intensity=0.3, colour=(0.7, 0.8, 1.0))
87 fill.look_at((0.8, -0.6, 1.0))
88 self.add_child(fill)
89
90 ground = MeshInstance3D(name="Ground", mesh=Mesh.cube())
91 ground.material = Material(colour=(0.6, 0.6, 0.62), roughness=0.85, metallic=0.0)
92 ground.scale = (40.0, 0.1, 40.0)
93 ground.position = (0.0, -0.05, 0.0)
94 self.add_child(ground)
95
96 palette = [
97 (0.85, 0.85, 0.85),
98 (0.8, 0.8, 0.82),
99 (0.75, 0.78, 0.85),
100 (0.82, 0.8, 0.78),
101 (0.78, 0.82, 0.8),
102 ]
103 rng = np.random.default_rng(3)
104 for i in range(20):
105 colour = palette[i % len(palette)]
106 mat = Material(colour=colour, roughness=0.6, metallic=0.05)
107 mesh = Mesh.cube() if i % 2 == 0 else Mesh.sphere(radius=0.6)
108 obj = MeshInstance3D(name=f"Obj{i}", mesh=mesh, material=mat)
109 ring = 3.0 + (i % 3) * 2.5
110 angle = i * math.pi * 2 / 7
111 obj.position = (
112 math.cos(angle) * ring + rng.uniform(-0.4, 0.4),
113 0.6 + (i % 3) * 0.3,
114 math.sin(angle) * ring + rng.uniform(-0.4, 0.4),
115 )
116 self.add_child(obj)
117
118 # Register the grading LUTs once the renderer (and its post-process pass)
119 # is live. ``app`` is available from on_ready onward.
120 self.app.register_lut(LUT_WARM, generate_warm_lut(32))
121 self.app.register_lut(LUT_COOL, generate_cool_lut(32))
122
123 self._hud = self.add_child(Text2D(name="HUD", text="", font_scale=1.4, position=(12.0, 12.0)))
124 self._build_grade_bar()
125 # Boot with the warm grade active so the effect is visible immediately;
126 # [1] / the None button returns to the neutral (ungraded) image.
127 self._set_grade(LUT_WARM)
128 self._update_camera()
129
130 def _build_grade_bar(self):
131 """Clickable grade switcher, anchored bottom-left so touch/mouse-only
132 viewers (web build) can swap grades without a keyboard."""
133 btn_w, btn_h = 86.0, 34.0
134 gutter = 12.0
135 bar_h = btn_h + 12.0
136 labels = [("None", 0), ("Warm", LUT_WARM), ("Cool", LUT_COOL)]
137 bar_w = 8.0 + (btn_w + 6.0) * len(labels)
138
139 bar = Panel(name="GradeBar")
140 bar.set_anchor_preset(AnchorPreset.BOTTOM_LEFT)
141 bar.margin_left = gutter
142 bar.margin_right = bar_w + gutter
143 bar.margin_top = -(bar_h + gutter)
144 bar.margin_bottom = -gutter
145 bar.bg_colour = Colour((0.0, 0.0, 0.0, 0.45))
146 self.add_child(bar)
147
148 self._grade_buttons: dict[int, Button] = {}
149 x = 8.0
150 for label, tex_id in labels:
151 btn = Button(label, name=f"Grade{label}", on_press=self._make_grade_handler(tex_id))
152 btn.position = Vec2(x, 6.0)
153 btn.size = Vec2(btn_w, btn_h)
154 bar.add_child(btn)
155 self._grade_buttons[tex_id] = btn
156 x += btn_w + 6.0
157
158 def _make_grade_handler(self, tex_id: int):
159 return lambda: self._set_grade(tex_id)
160
161 def _set_grade(self, tex_id: int):
162 env = self._env
163 env.lut_tex_id = tex_id
164 env.lut_enabled = tex_id != 0
165 for grade_id, btn in self._grade_buttons.items():
166 state = Button.VisualState.PRESSED if grade_id == tex_id else None
167 btn.set_visual_state_override(state)
168 self._update_hud()
169
170 def on_update(self, dt):
171 if Input.is_action_just_pressed("quit"):
172 self.app.quit()
173 return
174
175 if Input.is_action_pressed("orbit_left"):
176 self._yaw += 60.0 * dt
177 if Input.is_action_pressed("orbit_right"):
178 self._yaw -= 60.0 * dt
179 if Input.is_action_pressed("pitch_up"):
180 self._pitch = min(80.0, self._pitch + 30.0 * dt)
181 if Input.is_action_pressed("pitch_down"):
182 self._pitch = max(-10.0, self._pitch - 30.0 * dt)
183
184 if Input.is_action_just_pressed("lut_off"):
185 self._set_grade(0)
186 if Input.is_action_just_pressed("lut_warm"):
187 self._set_grade(LUT_WARM)
188 if Input.is_action_just_pressed("lut_cool"):
189 self._set_grade(LUT_COOL)
190
191 self._update_camera()
192
193 def _update_camera(self):
194 yaw_rad = math.radians(self._yaw)
195 pitch_rad = math.radians(self._pitch)
196 cp = math.cos(pitch_rad)
197 x = self._target[0] + self._distance * cp * math.sin(yaw_rad)
198 y = self._target[1] + self._distance * math.sin(pitch_rad)
199 z = self._target[2] + self._distance * cp * math.cos(yaw_rad)
200 self._cam.position = (x, y, z)
201 self._cam.look_at(self._target)
202
203 def _update_hud(self):
204 env = self._env
205 if not env.lut_enabled:
206 grade = "none (neutral)"
207 elif env.lut_tex_id == LUT_WARM:
208 grade = "warm (amber)"
209 elif env.lut_tex_id == LUT_COOL:
210 grade = "cool (teal)"
211 else:
212 grade = f"id {env.lut_tex_id}"
213 self._hud.text = "\n".join(
214 [
215 "3D LUT Colour Grading",
216 f"Active grade: {grade}",
217 "[1] none [2] warm [3] cool",
218 "A/D orbit W/S pitch Esc quit",
219 ]
220 )
221
222
223def main() -> None:
224 app = App(title="LutGrading", width=1280, height=720)
225 app.run(LutGrading())
226
227
228if __name__ == "__main__":
229 main()