Fog

Distance-based fog via WorldEnvironment.

▶ Run in browser

Tags: 3d

Distance fog blends geometry toward a fog colour the further it is from the camera, which sells depth and hides the far edge of a scene. It is a setting on the scene’s WorldEnvironment node, alongside bloom and tonemapping:

env = WorldEnvironment(fog_enabled=True, fog_colour=(0.95, 0.55, 0.25, 1.0), fog_density=0.05)

fog_mode picks the falloff curve, a FogMode. LINEAR ramps from fog_start to fog_end; EXPONENTIAL and EXPONENTIAL_SQUARED thicken with fog_density and ignore the start and end. Fog tints geometry only: the sky is left alone, which is why a warm orange fog stands out against the blue sky.

The scene rings a field of objects with emissive orbs that bloom and tall pillars that fade into the fog, and an OrbitCamera3D lets you walk the view in and out of it.

Controls: Drag / right stick - Orbit the camera Scroll / triggers - Zoom 1 - Toggle fog 2 - Toggle bloom 3 - Cycle fog mode Up / Down - Fog density (exponential modes) Right / Left - Tonemap exposure

Source

  1# /// simvx
  2# web = { width = 1280, height = 720, reason = "Fog renders differently than desktop (no tonemap exposure on web)." }
  3# ///
  4"""Fog: Distance-based fog via WorldEnvironment.
  5
  6Distance fog blends geometry toward a fog colour the further it is from the
  7camera, which sells depth and hides the far edge of a scene. It is a setting on
  8the scene's ``WorldEnvironment`` node, alongside bloom and tonemapping:
  9
 10    env = WorldEnvironment(fog_enabled=True, fog_colour=(0.95, 0.55, 0.25, 1.0), fog_density=0.05)
 11
 12``fog_mode`` picks the falloff curve, a ``FogMode``. ``LINEAR`` ramps from ``fog_start`` to
 13``fog_end``; ``EXPONENTIAL`` and ``EXPONENTIAL_SQUARED`` thicken with
 14``fog_density`` and ignore the start and end. Fog tints geometry only: the sky
 15is left alone, which is why a warm orange fog stands out against the blue sky.
 16
 17The scene rings a field of objects with emissive orbs that bloom and tall
 18pillars that fade into the fog, and an ``OrbitCamera3D`` lets you walk the view
 19in and out of it.
 20
 21Controls:
 22    Drag / right stick     - Orbit the camera
 23    Scroll / triggers      - Zoom
 24    1                      - Toggle fog
 25    2                      - Toggle bloom
 26    3                      - Cycle fog mode
 27    Up / Down              - Fog density (exponential modes)
 28    Right / Left           - Tonemap exposure
 29"""
 30
 31import math
 32
 33import numpy as np
 34
 35from simvx.core import (
 36    Anchor,
 37    Axis1D,
 38    CanvasLayer,
 39    DirectionalLight3D,
 40    FogMode,
 41    Input,
 42    Key,
 43    Label,
 44    Material,
 45    Mesh,
 46    MeshInstance3D,
 47    Node,
 48    OrbitCamera3D,
 49    Vec3,
 50    WorldEnvironment,
 51    on_input,
 52)
 53from simvx.graphics import App
 54
 55FOG_MODES = list(FogMode)
 56ORB_COLOURS = [(1.0, 0.15, 0.05), (0.05, 1.0, 0.25), (0.2, 0.3, 1.0), (1.0, 0.8, 0.1), (1.0, 0.1, 0.9), (0.1, 0.9, 1.0)]
 57PROP_COLOURS = [(0.9, 0.2, 0.2), (0.2, 0.9, 0.2), (0.2, 0.2, 0.9), (0.9, 0.9, 0.2), (1.0, 0.5, 0.0), (0.5, 0.0, 1.0)]
 58
 59
 60class FogDemo(Node):
 61    input_context = {
 62        "toggle_fog": [Key.KEY_1],
 63        "toggle_bloom": [Key.KEY_2],
 64        "cycle_fog_mode": [Key.KEY_3],
 65        "density": [Axis1D(low=Key.DOWN, high=Key.UP)],
 66        "exposure": [Axis1D(low=Key.LEFT, high=Key.RIGHT)],
 67    }
 68
 69    def on_ready(self):
 70        self.add_child(
 71            OrbitCamera3D(
 72                pivot=Vec3(0, 2, 0),
 73                distance=15.0,
 74                yaw=math.radians(30),
 75                pitch=math.radians(-25),
 76                min_distance=5.0,
 77                max_distance=60.0,
 78                min_pitch=math.radians(-80),
 79                max_pitch=math.radians(10),
 80                fov=60,
 81                far=200.0,
 82            )
 83        )
 84        # A low bloom threshold so the strongly emissive orbs clearly halo.
 85        self.env = self.add_child(
 86            WorldEnvironment(
 87                fog_enabled=True,
 88                fog_colour=(0.95, 0.55, 0.25, 1.0),
 89                fog_density=0.05,
 90                fog_start=2.0,
 91                fog_end=50.0,
 92                fog_mode=FogMode.EXPONENTIAL,
 93                bloom_enabled=True,
 94                bloom_threshold=0.8,
 95                bloom_intensity=1.2,
 96                bloom_soft_knee=0.7,
 97                tonemap_exposure=0.9,
 98            )
 99        )
100        self.add_child(DirectionalLight3D(intensity=1.5, direction=(-1, -2, -1)))
101        self.add_child(DirectionalLight3D(intensity=0.3, colour=(0.6, 0.7, 1.0), direction=(1, -1, 2)))
102        self.add_child(MeshInstance3D(mesh=Mesh.plane(50), material=Material(colour=(0.3, 0.35, 0.3), roughness=0.9)))
103
104        # emissive_colour is (r, g, b, intensity): the multiplier lifts the orbs far above the bloom threshold.
105        for i, rgb in enumerate(ORB_COLOURS):
106            angle = i * math.tau / len(ORB_COLOURS)
107            material = Material(
108                colour=tuple(c * 0.2 for c in rgb), roughness=0.25, metallic=0.9, emissive_colour=(*rgb, 5.0)
109            )
110            position = (math.cos(angle) * 6.0, 1.0, math.sin(angle) * 6.0)
111            self.add_child(MeshInstance3D(mesh=Mesh.sphere(radius=0.6), material=material, position=position))
112
113        # Props scattered near and far, so the fog visibly swallows the distant ones.
114        shapes = [(Mesh.sphere(radius=0.8), 0.8), (Mesh.box(), 0.5), (Mesh.cylinder(radius=0.5, height=2.0), 1.0)]
115        rng = np.random.default_rng(42)
116        for i in range(30):
117            mesh, y = shapes[i % len(shapes)]
118            material = Material(colour=PROP_COLOURS[i % len(PROP_COLOURS)], roughness=0.4, metallic=0.3)
119            position = (rng.uniform(-20, 20), y, rng.uniform(-20, 20))
120            self.add_child(MeshInstance3D(mesh=mesh, material=material, position=position))
121
122        pillar_material = Material(colour=(0.6, 0.6, 0.65), roughness=0.5, metallic=0.1)
123        for i in range(8):
124            angle = i * math.tau / 8
125            position = (math.cos(angle) * 15.0, 3.0, math.sin(angle) * 15.0)
126            self.add_child(MeshInstance3D(mesh=Mesh.box((0.8, 6.0, 0.8)), material=pillar_material, position=position))
127
128        self.hud = self.add_child(CanvasLayer()).add_child(Label(anchor=Anchor.TOP_LEFT, inset=10, font_size=16))
129
130    @on_input("toggle_fog")
131    def _toggle_fog(self, _event):
132        self.env.fog_enabled = not self.env.fog_enabled
133
134    @on_input("toggle_bloom")
135    def _toggle_bloom(self, _event):
136        self.env.bloom_enabled = not self.env.bloom_enabled
137
138    @on_input("cycle_fog_mode")
139    def _cycle_fog_mode(self, _event):
140        self.env.fog_mode = FOG_MODES[(FOG_MODES.index(self.env.fog_mode) + 1) % len(FOG_MODES)]
141
142    def on_update(self, dt):
143        env = self.env
144        env.fog_density = min(0.2, max(0.001, env.fog_density + Input.value("density") * 0.02 * dt))
145        env.tonemap_exposure = min(5.0, max(0.1, env.tonemap_exposure + Input.value("exposure") * dt))
146        self.hud.text = "\n".join(
147            [
148                f"[1] Fog: {'ON' if env.fog_enabled else 'OFF'}   Density: {env.fog_density:.3f} (Up/Down)",
149                f"[2] Bloom: {'ON' if env.bloom_enabled else 'OFF'}",
150                f"[3] Mode: {env.fog_mode}",
151                f"Exposure: {env.tonemap_exposure:.2f} (Right/Left)",
152                "Drag to orbit, scroll to zoom",
153            ]
154        )
155
156
157if __name__ == "__main__":
158    App(title="Fog", width=1280, height=720).run(FogDemo())