Volumetric Fog

Ray-marched single-scatter fog + localised FogVolume3D.

▶ Run in browser

Tags: 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())