World 2D in HDR

2D that tonemaps and blooms with the 3D scene.

▶ Run in browser

Tags: 3d 2d hdr post-processing bloom

Demonstrates the N1 “2D in HDR” model: when post-processing is on, world-space 2D (sprites / shapes / particles) renders INTO the HDR buffer before tonemap, so it gets exposure, tonemap and bloom consistently with the 3D scene. Screen-space 2D (HUD/UI) stays crisp post-tonemap at authored LDR. A per-node hdr override forces a node either way.

What to look for:

  • The bright 2D star (a world-space Polygon2D with colour > 1) BLOOMS, just like an emissive 3D surface – impossible before N1 (2D used to paste on flat).

  • The grey “auto” quad and the 3D cube darken/brighten TOGETHER as you change exposure (both go through tonemap).

  • The “hdr=False” quad keeps its exact authored colour at any exposure (the escape hatch for stylised flat 2D).

  • The HUD text stays crisp and fixed regardless of exposure (screen-space LDR).

Controls: Left / Right - Adjust tonemap exposure Space - Cycle the demo quad’s hdr override (auto / on / off) [desktop only] B - Toggle bloom A / D - Orbit camera Escape - Quit

Run: uv run python examples/features/3d/world_2d_in_hdr.py

Source

  1"""World 2D in HDR: 2D that tonemaps and blooms with the 3D scene.
  2
  3# /// simvx
  4# tags = ["2d", "hdr", "post-processing", "bloom"]
  5# web = { width = 1280, height = 720, reason = "Works on both backends; the hdr override (Space) is desktop-only." }
  6# ///
  7
  8Demonstrates the N1 "2D in HDR" model: when post-processing is on, world-space 2D
  9(sprites / shapes / particles) renders INTO the HDR buffer before tonemap, so it
 10gets exposure, tonemap and bloom consistently with the 3D scene. Screen-space 2D
 11(HUD/UI) stays crisp post-tonemap at authored LDR. A per-node ``hdr`` override
 12forces a node either way.
 13
 14What to look for:
 15  - The bright 2D star (a world-space Polygon2D with colour > 1) BLOOMS, just like
 16    an emissive 3D surface -- impossible before N1 (2D used to paste on flat).
 17  - The grey "auto" quad and the 3D cube darken/brighten TOGETHER as you change
 18    exposure (both go through tonemap).
 19  - The "hdr=False" quad keeps its exact authored colour at any exposure (the
 20    escape hatch for stylised flat 2D).
 21  - The HUD text stays crisp and fixed regardless of exposure (screen-space LDR).
 22
 23Controls:
 24    Left / Right - Adjust tonemap exposure
 25    Space        - Cycle the demo quad's hdr override (auto / on / off) [desktop only]
 26    B            - Toggle bloom
 27    A / D        - Orbit camera
 28    Escape       - Quit
 29
 30Run: uv run python examples/features/3d/world_2d_in_hdr.py
 31"""
 32
 33import math
 34
 35from simvx.core import (
 36    Camera3D,
 37    DirectionalLight3D,
 38    Input,
 39    InputMap,
 40    Key,
 41    Material,
 42    Mesh,
 43    MeshInstance3D,
 44    Node,
 45    Polygon2D,
 46    Text2D,
 47    WorldEnvironment,
 48)
 49from simvx.graphics import App
 50
 51HDR_CYCLE = [None, True, False]
 52HDR_LABEL = {None: "auto (by role)", True: "on (force HDR)", False: "off (flat LDR)"}
 53
 54
 55def _quad(x, y, w, h, colour):
 56    q = Polygon2D()
 57    q.polygon = [(0, 0), (w, 0), (w, h), (0, h)]
 58    q.colour = colour
 59    q.position = (x, y)
 60    return q
 61
 62
 63class World2DInHDR(Node):
 64    def on_ready(self):
 65        InputMap.add_action("exposure_up", [Key.RIGHT])
 66        InputMap.add_action("exposure_down", [Key.LEFT])
 67        InputMap.add_action("cycle_hdr", [Key.SPACE])
 68        InputMap.add_action("toggle_bloom", [Key.B])
 69        InputMap.add_action("orbit_left", [Key.A])
 70        InputMap.add_action("orbit_right", [Key.D])
 71        InputMap.add_action("quit", [Key.ESCAPE])
 72
 73        self._yaw = 25.0
 74        self._hdr_idx = 0
 75
 76        self._env = self.add_child(WorldEnvironment())
 77        self._env.bloom_enabled = True
 78        self._env.bloom_threshold = 0.9
 79        self._env.bloom_intensity = 1.1
 80        self._env.tonemap_mode = "aces"
 81        self._env.tonemap_exposure = 1.0
 82
 83        self._cam = self.add_child(Camera3D(fov=60, near=0.1, far=100.0))
 84
 85        key = DirectionalLight3D(intensity=2.0)
 86        key.look_at((-1.0, -1.5, -1.0))
 87        self.add_child(key)
 88
 89        # 3D content: a lit cube the world 2D is composited over.
 90        self._cube = self.add_child(
 91            MeshInstance3D(mesh=Mesh.cube(2.0), material=Material(colour=(0.75, 0.3, 0.3), roughness=0.5))
 92        )
 93        self._cube.position = (2.2, 0.0, 0.0)
 94
 95        # World-space 2D, by role -> HDR lane. A bright (>1) star that BLOOMS.
 96        star = _quad(80, 120, 0, 0, (2.4, 2.1, 0.6, 1.0))
 97        star.polygon = self._star_points(70)
 98        self.add_child(star)
 99
100        # The "auto" grey quad: tonemaps with the scene (compare to the cube).
101        self._auto_quad = self.add_child(_quad(60, 300, 200, 180, (0.6, 0.6, 0.6, 1.0)))
102
103        # The toggleable demo quad: Space cycles its hdr override.
104        self._demo_quad = self.add_child(_quad(300, 300, 200, 180, (0.6, 0.6, 0.6, 1.0)))
105
106        # Screen-space HUD (Text2D is screen_space by default): stays crisp LDR.
107        self._hud = self.add_child(Text2D(text="", font_scale=1.2, position=(10.0, 10.0)))
108        self._update_camera()
109        self._update_hud()
110
111    @staticmethod
112    def _star_points(r):
113        pts = []
114        for i in range(10):
115            ang = -math.pi / 2 + i * math.pi / 5
116            rad = r if i % 2 == 0 else r * 0.45
117            pts.append((math.cos(ang) * rad + r, math.sin(ang) * rad + r))
118        return pts
119
120    def on_update(self, dt):
121        if Input.is_action_just_pressed("quit"):
122            self.app.quit()
123            return
124        env = self._env
125        if Input.is_action_pressed("exposure_up"):
126            env.tonemap_exposure = min(5.0, env.tonemap_exposure + 1.0 * dt)
127        if Input.is_action_pressed("exposure_down"):
128            env.tonemap_exposure = max(0.1, env.tonemap_exposure - 1.0 * dt)
129        if Input.is_action_pressed("orbit_left"):
130            self._yaw += 60.0 * dt
131        if Input.is_action_pressed("orbit_right"):
132            self._yaw -= 60.0 * dt
133        if Input.is_action_just_pressed("toggle_bloom"):
134            env.bloom_enabled = not env.bloom_enabled
135        if Input.is_action_just_pressed("cycle_hdr"):
136            self._hdr_idx = (self._hdr_idx + 1) % len(HDR_CYCLE)
137            self._demo_quad.hdr = HDR_CYCLE[self._hdr_idx]
138        self._update_camera()
139        self._update_hud()
140
141    def _update_camera(self):
142        yaw = math.radians(self._yaw)
143        self._cam.position = (math.sin(yaw) * 8.0, 1.5, math.cos(yaw) * 8.0)
144        self._cam.look_at((0.0, 0.0, 0.0))
145
146    def _update_hud(self):
147        env = self._env
148        self._hud.text = "\n".join(
149            [
150                "World 2D in HDR (N1)",
151                f"Exposure: {env.tonemap_exposure:.2f} (Left/Right)",
152                f"Bloom: {'ON' if env.bloom_enabled else 'OFF'} (B)",
153                f"Demo quad hdr: {HDR_LABEL[HDR_CYCLE[self._hdr_idx]]} (Space)",
154                "Star blooms; auto quad tracks exposure; off quad stays flat; HUD fixed.",
155                "A/D orbit  Esc quit",
156            ]
157        )
158
159
160if __name__ == "__main__":
161    app = App(title="World 2D in HDR", width=1280, height=720)
162    app.run(World2DInHDR())