Fog¶
Distance-based fog via WorldEnvironment.
▶ Run in browserTags: 3d
Demonstrates:
Distance fog with a start/end range and live density adjustment
Fog colour control
Fog mode switching (linear / exponential / exponential_squared)
Bloom + tonemap combined with fog
Controls: Mouse drag - Orbit / pitch camera Scroll - Zoom in / out A / D - Orbit camera left / right W / S - Pitch camera up / down Q / E - Zoom in / out 1 - Toggle fog 2 - Toggle bloom 3 - Cycle fog mode Up / Down - Fog density up / down Right / Left - Tonemap exposure up / down Escape - Quit
Run: uv run python examples/features/3d/fog.py
Source¶
1"""Fog: Distance-based fog via WorldEnvironment.
2
3# /// simvx
4# web = { width = 1280, height = 720, reason = "Fog renders differently than desktop (no tonemap exposure on web)." }
5# ///
6
7Demonstrates:
8 - Distance fog with a start/end range and live density adjustment
9 - Fog colour control
10 - Fog mode switching (linear / exponential / exponential_squared)
11 - Bloom + tonemap combined with fog
12
13Controls:
14 Mouse drag - Orbit / pitch camera
15 Scroll - Zoom in / out
16 A / D - Orbit camera left / right
17 W / S - Pitch camera up / down
18 Q / E - Zoom in / out
19 1 - Toggle fog
20 2 - Toggle bloom
21 3 - Cycle fog mode
22 Up / Down - Fog density up / down
23 Right / Left - Tonemap exposure up / down
24 Escape - Quit
25
26Run: uv run python examples/features/3d/fog.py
27"""
28
29import math
30
31import numpy as np
32
33from simvx.core import (
34 Camera3D,
35 DirectionalLight3D,
36 Input,
37 Key,
38 Material,
39 Mesh,
40 MeshInstance3D,
41 MouseButton,
42 Node,
43 Text2D,
44 WorldEnvironment,
45)
46from simvx.graphics import App
47
48FOG_MODES = ["linear", "exponential", "exponential_squared"]
49
50
51class FogDemo(Node):
52 input_actions = {
53 "orbit_left": [Key.A],
54 "orbit_right": [Key.D],
55 "pitch_up": [Key.W],
56 "pitch_down": [Key.S],
57 "zoom_in": [Key.Q],
58 "zoom_out": [Key.E],
59 "toggle_fog": [Key.KEY_1],
60 "toggle_bloom": [Key.KEY_2],
61 "cycle_fog_mode": [Key.KEY_3],
62 "density_up": [Key.UP],
63 "density_down": [Key.DOWN],
64 "exposure_up": [Key.RIGHT],
65 "exposure_down": [Key.LEFT],
66 "quit": [Key.ESCAPE],
67 }
68
69 def on_ready(self):
70 self._yaw = 30.0
71 self._pitch = 25.0
72 self._distance = 15.0
73 self._target = (0.0, 2.0, 0.0)
74 self._fog_mode_idx = 1 # exponential
75
76 self._cam = Camera3D(name="Camera", fov=60, near=0.1, far=200.0)
77 self.add_child(self._cam)
78
79 # WorldEnvironment: fog + bloom + tonemap. Warm orange fog contrasts the
80 # blue gradient sky so distance fog is obvious when toggled, and bloom
81 # threshold is low enough that the strongly-emissive balls clearly halo.
82 self._env = self.add_child(WorldEnvironment())
83 self._env.fog_enabled = True
84 self._env.fog_colour = (0.95, 0.55, 0.25, 1.0)
85 self._env.fog_density = 0.05
86 self._env.fog_start = 2.0
87 self._env.fog_end = 50.0
88 self._env.fog_mode = "exponential"
89 self._env.bloom_enabled = True
90 self._env.bloom_threshold = 0.8
91 self._env.bloom_intensity = 1.2
92 self._env.bloom_soft_knee = 0.7
93 self._env.tonemap_exposure = 0.9
94
95 # Lighting
96 key = DirectionalLight3D(name="KeyLight", intensity=1.5)
97 key.look_at((-1.0, -2.0, -1.0))
98 self.add_child(key)
99
100 fill = DirectionalLight3D(name="FillLight", intensity=0.3, colour=(0.6, 0.7, 1.0))
101 fill.look_at((1.0, -1.0, 2.0))
102 self.add_child(fill)
103
104 # Ground plane
105 ground = MeshInstance3D(name="Ground", mesh=Mesh.cube())
106 ground.material = Material(colour=(0.3, 0.35, 0.3), roughness=0.9, metallic=0.0)
107 ground.scale = (50.0, 0.1, 50.0)
108 ground.position = (0.0, -0.05, 0.0)
109 self.add_child(ground)
110
111 # Strongly-emissive metallic orbs to show bloom. ``emissive_colour`` is
112 # (r, g, b, intensity): the intensity multiplier pushes the fragment
113 # HDR value well above the bloom threshold so the halo is unmistakable
114 # when bloom is on and disappears entirely when toggled off.
115 emissive_specs = [
116 ((1.0, 0.15, 0.05), 6.0), # fiery red
117 ((0.05, 1.0, 0.25), 5.0), # emerald
118 ((0.2, 0.3, 1.0), 5.0), # electric blue
119 ((1.0, 0.8, 0.1), 6.0), # amber
120 ((1.0, 0.1, 0.9), 5.0), # magenta
121 ((0.1, 0.9, 1.0), 5.0), # cyan
122 ]
123 for i, (rgb, intensity) in enumerate(emissive_specs):
124 angle = i * math.pi * 2 / len(emissive_specs)
125 mat = Material(
126 colour=(rgb[0] * 0.2, rgb[1] * 0.2, rgb[2] * 0.2),
127 roughness=0.25,
128 metallic=0.9,
129 emissive_colour=(*rgb, intensity),
130 )
131 obj = MeshInstance3D(name=f"Emissive{i}", mesh=Mesh.sphere(radius=0.6), material=mat)
132 obj.position = (math.cos(angle) * 6.0, 1.0, math.sin(angle) * 6.0)
133 self.add_child(obj)
134
135 # Scattered objects at various distances: fog fades distant ones
136 colours = [
137 (0.9, 0.2, 0.2),
138 (0.2, 0.9, 0.2),
139 (0.2, 0.2, 0.9),
140 (0.9, 0.9, 0.2),
141 (0.9, 0.2, 0.9),
142 (0.2, 0.9, 0.9),
143 (1.0, 0.5, 0.0),
144 (0.5, 0.0, 1.0),
145 ]
146 rng = np.random.default_rng(42)
147 for i in range(30):
148 colour = colours[i % len(colours)]
149 mat = Material(colour=colour, roughness=0.4, metallic=0.3)
150 if i % 3 == 0:
151 mesh = Mesh.sphere(radius=0.8)
152 elif i % 3 == 1:
153 mesh = Mesh.cube()
154 else:
155 mesh = Mesh.cylinder(radius=0.5, height=2.0)
156 obj = MeshInstance3D(name=f"Obj{i}", mesh=mesh, material=mat)
157 obj.position = (rng.uniform(-20, 20), 0.8 if i % 3 != 2 else 1.0, rng.uniform(-20, 20))
158 self.add_child(obj)
159
160 # Tall pillars (visible at distance, good for fog depth testing)
161 pillar_mat = Material(colour=(0.6, 0.6, 0.65), roughness=0.5, metallic=0.1)
162 for i in range(8):
163 angle = i * math.pi * 2 / 8
164 pillar = MeshInstance3D(name=f"Pillar{i}", mesh=Mesh.cube(), material=pillar_mat)
165 pillar.scale = (0.8, 6.0, 0.8)
166 pillar.position = (math.cos(angle) * 15.0, 3.0, math.sin(angle) * 15.0)
167 self.add_child(pillar)
168
169 self._hud = self.add_child(Text2D(name="HUD", text="", font_scale=1.2, position=(10.0, 10.0)))
170 self._update_camera()
171
172 def on_update(self, dt):
173 if Input.is_action_pressed("orbit_left"):
174 self._yaw += 60.0 * dt
175 if Input.is_action_pressed("orbit_right"):
176 self._yaw -= 60.0 * dt
177 if Input.is_action_pressed("zoom_in"):
178 self._distance = max(5.0, self._distance - 10.0 * dt)
179 if Input.is_action_pressed("zoom_out"):
180 self._distance = min(60.0, self._distance + 10.0 * dt)
181 if Input.is_action_pressed("pitch_up"):
182 self._pitch = min(80.0, self._pitch + 30.0 * dt)
183 if Input.is_action_pressed("pitch_down"):
184 self._pitch = max(-10.0, self._pitch - 30.0 * dt)
185
186 # Mouse-drag orbit/pitch and scroll-wheel zoom
187 if Input.is_mouse_button_pressed(MouseButton.LEFT):
188 delta = Input.mouse_delta
189 self._yaw -= float(delta.x) * 0.3
190 self._pitch = max(-10.0, min(80.0, self._pitch + float(delta.y) * 0.3))
191 scroll = Input.scroll_delta
192 if scroll[1] != 0.0:
193 self._distance = max(5.0, min(60.0, self._distance - scroll[1] * 1.5))
194
195 if Input.is_action_just_pressed("quit"):
196 self.app.quit()
197 return
198
199 env = self._env
200
201 if Input.is_action_just_pressed("toggle_fog"):
202 env.fog_enabled = not env.fog_enabled
203 if Input.is_action_just_pressed("toggle_bloom"):
204 env.bloom_enabled = not env.bloom_enabled
205 if Input.is_action_just_pressed("cycle_fog_mode"):
206 self._fog_mode_idx = (self._fog_mode_idx + 1) % len(FOG_MODES)
207 env.fog_mode = FOG_MODES[self._fog_mode_idx]
208
209 if Input.is_action_pressed("density_up"):
210 env.fog_density = min(0.2, env.fog_density + 0.02 * dt)
211 if Input.is_action_pressed("density_down"):
212 env.fog_density = max(0.001, env.fog_density - 0.02 * dt)
213
214 if Input.is_action_pressed("exposure_up"):
215 env.tonemap_exposure = min(5.0, env.tonemap_exposure + 1.0 * dt)
216 if Input.is_action_pressed("exposure_down"):
217 env.tonemap_exposure = max(0.1, env.tonemap_exposure - 1.0 * dt)
218
219 self._update_camera()
220 self._update_hud()
221
222 def _update_camera(self):
223 yaw_rad = math.radians(self._yaw)
224 pitch_rad = math.radians(self._pitch)
225 cp = math.cos(pitch_rad)
226 x = self._target[0] + self._distance * cp * math.sin(yaw_rad)
227 y = self._target[1] + self._distance * math.sin(pitch_rad)
228 z = self._target[2] + self._distance * cp * math.cos(yaw_rad)
229 self._cam.position = (x, y, z)
230 self._cam.look_at(self._target)
231
232 def _update_hud(self):
233 env = self._env
234 lines = [
235 "Fog Demo (WorldEnvironment)",
236 f"[1] Fog: {'ON' if env.fog_enabled else 'OFF'} Density: {env.fog_density:.3f} (Up/Down)",
237 f"[2] Bloom: {'ON' if env.bloom_enabled else 'OFF'}",
238 f"[3] Mode: {env.fog_mode}",
239 f" Exposure: {env.tonemap_exposure:.2f} (Right/Left)",
240 "Drag orbit Scroll zoom A/D orbit W/S pitch Q/E zoom Esc quit",
241 ]
242 self._hud.text = "\n".join(lines)
243
244
245if __name__ == "__main__":
246 app = App(title="Fog Demo", width=1280, height=720)
247 app.run(FogDemo())