World 2D in HDR

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

▶ Run in browser

Tags: 3d 2d hdr post-processing bloom

When a WorldEnvironment turns post-processing on, 2D drawn in the world (sprites, shapes, particles) renders into the same HDR buffer as the 3D scene, before tonemapping. It therefore gets the scene’s exposure, tonemap curve and bloom, and a 2D colour brighter than 1.0 glows exactly like an emissive 3D surface. 2D pinned to the screen by a CanvasLayer (a HUD, menus) is composited afterwards at its authored colour, so text stays crisp whatever the exposure.

Every 2D drawable has a post_processed Property that overrides the choice:

Polygon2D(polygon=points, colour=(0.6, 0.6, 0.6, 1.0), post_processed=False)

None (the default) follows placement as described above, True forces the post chain on, and False keeps the authored colour, the escape hatch for flat, stylised 2D art in an HDR scene.

What to look for:

  • The star’s colour is well above 1.0, so it blooms.

  • The left grey quad follows placement: it darkens and brightens with the red cube as you change the exposure.

  • The right grey quad cycles through the three post_processed values; set to off it keeps its authored grey at any exposure.

  • The HUD text never changes brightness.

The buttons are Button(action=name): each presses its action’s key, so one @on_input handler serves the key and the button alike. The exposure slider holds the exposure the environment follows, and the arrow keys move it.

Controls: Slider / Left / Right - Tonemap exposure Cycle button / Space - Cycle the right quad’s post_processed (auto / on / off) Bloom button / B - Toggle bloom Drag / A / D - Orbit the camera

Source

  1# /// simvx
  2# tags = ["2d", "hdr", "post-processing", "bloom"]
  3# web = { width = 1280, height = 720 }
  4# ///
  5"""World 2D in HDR: 2D that tonemaps and blooms with the 3D scene.
  6
  7When a ``WorldEnvironment`` turns post-processing on, 2D drawn in the world
  8(sprites, shapes, particles) renders into the same HDR buffer as the 3D scene,
  9before tonemapping. It therefore gets the scene's exposure, tonemap curve and
 10bloom, and a 2D colour brighter than 1.0 glows exactly like an emissive 3D
 11surface. 2D pinned to the screen by a ``CanvasLayer`` (a HUD, menus) is
 12composited afterwards at its authored colour, so text stays crisp whatever the
 13exposure.
 14
 15Every 2D drawable has a ``post_processed`` Property that overrides the choice:
 16
 17    Polygon2D(polygon=points, colour=(0.6, 0.6, 0.6, 1.0), post_processed=False)
 18
 19``None`` (the default) follows placement as described above, ``True`` forces
 20the post chain on, and ``False`` keeps the authored colour, the escape hatch
 21for flat, stylised 2D art in an HDR scene.
 22
 23What to look for:
 24  - The star's colour is well above 1.0, so it blooms.
 25  - The left grey quad follows placement: it darkens and brightens with the
 26    red cube as you change the exposure.
 27  - The right grey quad cycles through the three ``post_processed`` values;
 28    set to off it keeps its authored grey at any exposure.
 29  - The HUD text never changes brightness.
 30
 31The buttons are ``Button(action=name)``: each presses its action's key, so
 32one ``@on_input`` handler serves the key and the button alike. The exposure
 33slider holds the exposure the environment follows, and the arrow keys move it.
 34
 35Controls:
 36    Slider / Left / Right          - Tonemap exposure
 37    Cycle button / Space           - Cycle the right quad's post_processed (auto / on / off)
 38    Bloom button / B               - Toggle bloom
 39    Drag / A / D                   - Orbit the camera
 40"""
 41
 42import math
 43
 44from simvx.core import (
 45    Anchor,
 46    Axis1D,
 47    Button,
 48    CanvasLayer,
 49    DirectionalLight3D,
 50    HBoxContainer,
 51    Input,
 52    Key,
 53    Label,
 54    Material,
 55    Mesh,
 56    MeshInstance3D,
 57    Node,
 58    OrbitCamera3D,
 59    Polygon2D,
 60    Slider,
 61    TonemapMode,
 62    WorldEnvironment,
 63    on_input,
 64)
 65from simvx.graphics import App
 66
 67POST_MODES = {None: "auto (by placement)", True: "on (forced)", False: "off (authored colour)"}
 68QUAD = [(0, 0), (200, 0), (200, 180), (0, 180)]
 69GREY = (0.6, 0.6, 0.6, 1.0)
 70
 71
 72def star(radius):
 73    """The ten vertices of a five-pointed star inside a box of side ``2 * radius``."""
 74    points = []
 75    for i in range(10):
 76        angle = -math.pi / 2 + i * math.pi / 5
 77        r = radius if i % 2 == 0 else radius * 0.45
 78        points.append((radius + math.cos(angle) * r, radius + math.sin(angle) * r))
 79    return points
 80
 81
 82class World2DInHDR(Node):
 83    input_context = {
 84        "exposure": [Axis1D(low=Key.LEFT, high=Key.RIGHT)],
 85        "orbit": [Axis1D(low=Key.A, high=Key.D)],
 86        "cycle_post": [Key.SPACE],
 87        "toggle_bloom": [Key.B],
 88    }
 89
 90    def on_ready(self):
 91        self.env = self.add_child(
 92            WorldEnvironment(
 93                bloom_enabled=True, bloom_threshold=0.9, bloom_intensity=1.1, tonemap_mode=TonemapMode.ACES
 94            )
 95        )
 96        self.camera = self.add_child(
 97            OrbitCamera3D(distance=8.0, yaw=math.radians(25), pitch=math.radians(-11), fov=60, far=100.0)
 98        )
 99        self.add_child(DirectionalLight3D(intensity=2.0, direction=(-1.0, -1.5, -1.0)))
100        cube_material = Material(colour=(0.75, 0.3, 0.3), roughness=0.5)
101        self.add_child(MeshInstance3D(mesh=Mesh.box(2.0), material=cube_material, position=(2.2, 0.0, 0.0)))
102
103        # World-space 2D: drawn over the 3D scene, inside the HDR buffer.
104        self.add_child(Polygon2D(polygon=star(70), colour=(2.4, 2.1, 0.6, 1.0), position=(80, 120)))
105        self.add_child(Polygon2D(polygon=QUAD, colour=GREY, position=(60, 300)))
106        self.demo_quad = self.add_child(Polygon2D(polygon=QUAD, colour=GREY, position=(300, 300)))
107
108        hud = self.add_child(CanvasLayer())
109        self.hud = hud.add_child(Label(anchor=Anchor.TOP_LEFT, inset=10, font_size=18))
110        bar = hud.add_child(HBoxContainer(anchor=Anchor.BOTTOM_LEFT, inset=12))
111        bar.add_child(Label("Exposure"))
112        self.exposure = bar.add_child(Slider(0.1, 5.0, value=self.env.tonemap_exposure))
113        self.exposure.step = 0.05
114        bar.add_child(Button("Bloom", action="toggle_bloom"))
115        bar.add_child(Button("Cycle post_processed", action="cycle_post"))
116
117    @on_input("cycle_post")
118    def _cycle_post(self, _event):
119        modes = list(POST_MODES)
120        self.demo_quad.post_processed = modes[(modes.index(self.demo_quad.post_processed) + 1) % len(modes)]
121
122    @on_input("toggle_bloom")
123    def _toggle_bloom(self, _event):
124        self.env.bloom_enabled = not self.env.bloom_enabled
125
126    def on_update(self, dt):
127        env = self.env
128        slider = self.exposure
129        slider.value = min(slider.max_value, max(slider.min_value, slider.value + Input.value("exposure") * dt))
130        env.tonemap_exposure = slider.value
131        self.camera.orbit(-Input.value("orbit") * math.radians(60) * dt, 0.0)
132        self.hud.text = "\n".join(
133            [
134                f"Exposure: {env.tonemap_exposure:.2f} (slider or Left/Right)",
135                f"Bloom: {'ON' if env.bloom_enabled else 'OFF'}",
136                f"Right quad post_processed: {POST_MODES[self.demo_quad.post_processed]}",
137                "Drag or A/D to orbit",
138            ]
139        )
140
141
142if __name__ == "__main__":
143    App(title="World 2D in HDR", width=1280, height=720).run(World2DInHDR())