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.

  • A localised FogVolume3D box of denser fog modulating the global march.

  • 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 box 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 uv run python examples/features/3d/volumetric_fog.py –test

Source

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