World 2D in HDR¶
2D that tonemaps and blooms with the 3D scene.
▶ Run in browserTags: 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_processedvalues; 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())