Fog

Distance-based fog via WorldEnvironment.

▶ Run in browser

Tags: 3d

Demonstrates:

  • Distance fog with adjustable density/start/end

  • Fog colour control

  • Height fog toggle

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