Volumetric Fog¶
Ray-marched single-scatter fog + localised FogVolume3D.
▶ Run in browserTags: 3d
Demonstrates:
Global volumetric fog driven by WorldEnvironment (density, anisotropy, albedo, height gradient): a single-scatter ray-march in HDR space.
Two localised FogVolume3D nodes modulating the global march: a warm orange box on the left and a cool teal sphere on the right, each adding density and overriding the in-scatter colour inside its shape. The same scene fogs identically on desktop (Vulkan) and web (WebGPU).
A light shaft: a tight directional sun + forward-scattering anisotropy so the in-scatter haloes the light direction.
Controls: A / D - Orbit camera left / right W / S - Pitch camera up / down Q / E - Zoom in / out 1 - Toggle volumetric fog 2 - Toggle the localised FogVolume3D nodes (box + sphere) Up / Down - Adjust global fog density Left / Right - Adjust anisotropy (forward/back scatter) Escape - Quit
Run: uv run python examples/features/3d/volumetric_fog.py
Source¶
1"""Volumetric Fog: Ray-marched single-scatter fog + localised FogVolume3D.
2
3# /// simvx
4# web = { width = 1280, height = 720 }
5# ///
6
7Demonstrates:
8 - Global volumetric fog driven by WorldEnvironment (density, anisotropy,
9 albedo, height gradient): a single-scatter ray-march in HDR space.
10 - Two localised FogVolume3D nodes modulating the global march: a warm
11 orange box on the left and a cool teal sphere on the right, each adding
12 density and overriding the in-scatter colour inside its shape. The same
13 scene fogs identically on desktop (Vulkan) and web (WebGPU).
14 - A light shaft: a tight directional sun + forward-scattering anisotropy
15 so the in-scatter haloes the light direction.
16
17Controls:
18 A / D - Orbit camera left / right
19 W / S - Pitch camera up / down
20 Q / E - Zoom in / out
21 1 - Toggle volumetric fog
22 2 - Toggle the localised FogVolume3D nodes (box + sphere)
23 Up / Down - Adjust global fog density
24 Left / Right - Adjust anisotropy (forward/back scatter)
25 Escape - Quit
26
27Run: uv run python examples/features/3d/volumetric_fog.py
28"""
29
30import math
31
32from simvx.core import (
33 Camera3D,
34 DirectionalLight3D,
35 Input,
36 InputMap,
37 Key,
38 Material,
39 Mesh,
40 MeshInstance3D,
41 Node,
42 Text2D,
43 WorldEnvironment,
44)
45from simvx.core.fog_volume import FogVolume3D, FogVolumeShape
46from simvx.graphics import App
47
48WIDTH, HEIGHT = 1280, 720
49
50
51class VolumetricFogDemo(Node):
52 def on_ready(self):
53 InputMap.add_action("orbit_left", [Key.A])
54 InputMap.add_action("orbit_right", [Key.D])
55 InputMap.add_action("pitch_up", [Key.W])
56 InputMap.add_action("pitch_down", [Key.S])
57 InputMap.add_action("zoom_in", [Key.Q])
58 InputMap.add_action("zoom_out", [Key.E])
59 InputMap.add_action("toggle_fog", [Key.KEY_1])
60 InputMap.add_action("toggle_volume", [Key.KEY_2])
61 InputMap.add_action("density_up", [Key.UP])
62 InputMap.add_action("density_down", [Key.DOWN])
63 InputMap.add_action("aniso_up", [Key.RIGHT])
64 InputMap.add_action("aniso_down", [Key.LEFT])
65 InputMap.add_action("quit", [Key.ESCAPE])
66
67 self._yaw = 35.0
68 self._pitch = 18.0
69 self._distance = 26.0
70 self._target = (0.0, 2.0, 0.0)
71
72 self._cam = self.add_child(Camera3D(name="Camera", fov=60, near=0.1, far=200.0))
73
74 # Global volumetric fog. Strong forward anisotropy + a tight sun gives a
75 # visible light shaft toward the camera; height gradient keeps the fog
76 # pooled near the ground.
77 env = self.add_child(WorldEnvironment())
78 env.sky_mode = "colour"
79 env.sky_colour_top = (0.05, 0.07, 0.12, 1.0)
80 env.sky_colour_bottom = (0.10, 0.12, 0.16, 1.0)
81 env.volumetric_fog_enabled = True
82 env.volumetric_fog_density = 0.018
83 env.volumetric_fog_length = 80.0
84 env.volumetric_fog_anisotropy = 0.7
85 env.volumetric_fog_albedo = (0.85, 0.9, 1.0, 1.0)
86 # Ambient in-scatter so the fog reads as a luminous veil rather than just
87 # darkening the scene; the height gradient keeps it pooled near the floor.
88 env.volumetric_fog_gi_inject = 0.45
89 env.fog_height = 0.0
90 env.fog_height_density = 0.02
91 env.bloom_enabled = True
92 env.bloom_threshold = 0.9
93 env.tonemap_exposure = 1.0
94 self._env = env
95
96 # Tight key light: the sun the fog scatters. look_at sets the travel
97 # direction; the fog shader negates it for the "toward the sun" vector.
98 key = DirectionalLight3D(name="Sun", intensity=3.0, colour=(1.0, 0.95, 0.85))
99 key.look_at((-0.6, -0.5, -0.7))
100 self.add_child(key)
101 fill = DirectionalLight3D(name="Fill", intensity=0.25, colour=(0.5, 0.6, 0.9))
102 fill.look_at((1.0, -0.5, 1.0))
103 self.add_child(fill)
104
105 # Ground + pillars to catch shafts and give the fog depth reference.
106 ground = MeshInstance3D(name="Ground", mesh=Mesh.cube())
107 ground.material = Material(colour=(0.18, 0.2, 0.22), roughness=0.9)
108 ground.scale = (60.0, 0.1, 60.0)
109 ground.position = (0.0, -0.05, 0.0)
110 self.add_child(ground)
111
112 pillar_mat = Material(colour=(0.55, 0.55, 0.6), roughness=0.5)
113 for i in range(8):
114 a = i * math.tau / 8
115 p = MeshInstance3D(name=f"Pillar{i}", mesh=Mesh.cube(), material=pillar_mat)
116 p.scale = (0.8, 7.0, 0.8)
117 p.position = (math.cos(a) * 12.0, 3.5, math.sin(a) * 12.0)
118 self.add_child(p)
119
120 emissive = Material(colour=(0.2, 0.2, 0.05), roughness=0.3,
121 emissive_colour=(1.0, 0.85, 0.3, 6.0))
122 orb = MeshInstance3D(name="Orb", mesh=Mesh.sphere(radius=0.8), material=emissive)
123 orb.position = (0.0, 2.0, 0.0)
124 self.add_child(orb)
125
126 # Localised denser fog. A warm box of thick orange mist offset to one
127 # side, plus a cool teal sphere on the other: their colour + density
128 # both contrast the thin cool global haze, so the FogVolume3D
129 # modulation is unmistakable (and identical on desktop + web).
130 vol = FogVolume3D(name="DenseBox", position=(-6.0, 3.0, 0.0))
131 vol.shape = FogVolumeShape.BOX
132 vol.size = (10.0, 7.0, 10.0)
133 vol.density = 1.0
134 vol.albedo = (1.0, 0.5, 0.15, 1.0) # warm: contrasts the cool global haze
135 vol.falloff = 1.5
136 vol.priority = 1
137 self.add_child(vol)
138 self._volume = vol
139
140 sphere_vol = FogVolume3D(name="DenseSphere", position=(6.0, 3.0, 0.0))
141 sphere_vol.shape = FogVolumeShape.SPHERE
142 sphere_vol.size = (9.0, 9.0, 9.0)
143 sphere_vol.density = 0.9
144 sphere_vol.albedo = (0.15, 0.7, 0.85, 1.0) # cool teal
145 sphere_vol.falloff = 1.0
146 sphere_vol.priority = 1
147 self.add_child(sphere_vol)
148 self._sphere_volume = sphere_vol
149
150 self._hud = self.add_child(Text2D(name="HUD", text="", font_scale=1.8, position=(12.0, 12.0)))
151 self._update_camera()
152
153 def on_update(self, dt):
154 if Input.is_action_just_pressed("quit"):
155 self.app.quit()
156 return
157 if Input.is_action_pressed("orbit_left"):
158 self._yaw += 60.0 * dt
159 if Input.is_action_pressed("orbit_right"):
160 self._yaw -= 60.0 * dt
161 if Input.is_action_pressed("pitch_up"):
162 self._pitch = min(80.0, self._pitch + 30.0 * dt)
163 if Input.is_action_pressed("pitch_down"):
164 self._pitch = max(-10.0, self._pitch - 30.0 * dt)
165 if Input.is_action_pressed("zoom_in"):
166 self._distance = max(6.0, self._distance - 12.0 * dt)
167 if Input.is_action_pressed("zoom_out"):
168 self._distance = min(70.0, self._distance + 12.0 * dt)
169
170 env = self._env
171 if Input.is_action_just_pressed("toggle_fog"):
172 env.volumetric_fog_enabled = not env.volumetric_fog_enabled
173 if Input.is_action_just_pressed("toggle_volume"):
174 vis = not self._volume.visible
175 self._volume.visible = vis
176 self._sphere_volume.visible = vis
177 if Input.is_action_pressed("density_up"):
178 env.volumetric_fog_density = min(0.3, env.volumetric_fog_density + 0.05 * dt)
179 if Input.is_action_pressed("density_down"):
180 env.volumetric_fog_density = max(0.0, env.volumetric_fog_density - 0.05 * dt)
181 if Input.is_action_pressed("aniso_up"):
182 env.volumetric_fog_anisotropy = min(0.95, env.volumetric_fog_anisotropy + 0.5 * dt)
183 if Input.is_action_pressed("aniso_down"):
184 env.volumetric_fog_anisotropy = max(-0.95, env.volumetric_fog_anisotropy - 0.5 * dt)
185
186 self._update_camera()
187 self._update_hud()
188
189 def _update_camera(self):
190 yaw, pitch = math.radians(self._yaw), math.radians(self._pitch)
191 cp = math.cos(pitch)
192 self._cam.position = (
193 self._target[0] + self._distance * cp * math.sin(yaw),
194 self._target[1] + self._distance * math.sin(pitch),
195 self._target[2] + self._distance * cp * math.cos(yaw),
196 )
197 self._cam.look_at(self._target)
198
199 def _update_hud(self):
200 env = self._env
201 self._hud.text = "\n".join([
202 "Volumetric Fog",
203 f"[1] Fog: {'ON' if env.volumetric_fog_enabled else 'OFF'} "
204 f"Density: {env.volumetric_fog_density:.3f} (Up/Down)",
205 f"[2] FogVolume3D nodes: {'ON' if self._volume.visible else 'OFF'}",
206 f" Anisotropy: {env.volumetric_fog_anisotropy:+.2f} (Left/Right)",
207 "A/D orbit W/S pitch Q/E zoom Esc quit",
208 ])
209
210
211if __name__ == "__main__":
212 app = App(title="Volumetric Fog", width=WIDTH, height=HEIGHT)
213 app.run(VolumetricFogDemo())