LUT Grading

3D-LUT colour grading via WorldEnvironment.

▶ Run in browser

Tags: 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()