Fog

Distance-based fog via WorldEnvironment.

▶ Run in browser

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