Source code for simvx.core.rain

"""Rain3D: a camera-relative GPU rain volume (design RM-E3).

A thin specialisation of :class:`GPUParticles3D` that fills a volume around the
active camera with fast-falling droplets and slants them with the scene wind. It
reuses the GPU particle simulation wholesale (there is no new render pass and no
new GPU binding): the only rain-specific behaviour is

  1. snapping the emitter to the active camera each frame so the downpour always
     surrounds the viewer (a camera-relative volume), and
  2. reading the ``WorldEnvironment`` wind -- the same source that populates the
     FrameGlobals wind slot the wet-surface shader reads -- to slant the fall.

Zero-cost when unused: a scene with no ``Rain3D`` emits nothing and touches no
new code path. Pair it with ``wetness_affected`` materials and a
``WorldEnvironment`` whose ``wetness`` / ``rain_intensity`` / ``ripple_strength``
are non-zero to make the ground read as wet under the downpour.
"""

from __future__ import annotations

from .descriptors import Property
from .gpu_particles import GPUParticles3D, _merge_properties
from .node import Node

__all__ = ["Rain3D"]


[docs] @_merge_properties class Rain3D(GPUParticles3D): """A camera-following GPU rain emitter. Example:: rain = scene.add_child(Rain3D(radius=16.0, fall_speed=22.0, amount=8000)) ``radius``/``height`` size the falling volume centred above the camera; ``fall_speed`` is the downward launch speed; ``wind_response`` scales how much the ``WorldEnvironment`` wind slants the drops. All the underlying emitter dials (``amount``, ``lifetime``, ``start_colour`` ...) remain available. """ # Rain-facing dials (the falling volume + wind slant). The base emitter # dials (amount, lifetime, colours, scale) are inherited and can be tuned. radius = Property(14.0, range=(1.0, 200.0), hint="Radius of the fall volume around the camera", group="Rain") height = Property(9.0, range=(0.0, 200.0), hint="Height of the volume centre above the camera", group="Rain") fall_speed = Property(18.0, range=(0.0, 200.0), clamp=False, hint="Downward droplet speed", group="Rain") wind_response = Property(1.0, range=(0.0, 10.0), hint="How strongly wind slants the fall", group="Rain") follow_camera = Property(True, hint="Keep the volume centred on the active camera", group="Rain") def __init__(self, **kwargs): # Rain-shaped defaults for the inherited emitter dials; explicit kwargs # win so a caller can still override any of them. defaults = { "amount": 6000, "lifetime": 1.1, "spread": 0.05, "start_colour": (0.72, 0.80, 0.95, 0.5), "end_colour": (0.72, 0.80, 0.95, 0.25), "start_scale": 0.05, "end_scale": 0.05, # Rain opts into velocity-stretched streak billboards so the drops # read as falling rain rather than round snow/bokeh. The value is # seconds of motion the sprite is stretched across (see # GPUParticles.streak); at the default fall speed this is a slim, # wind-slanted streak a few sprite-widths long. "streak": 0.03, } super().__init__(**{**defaults, **kwargs}) self._cached_camera = None # -- helpers ----------------------------------------------------------- def _scene_root(self) -> Node: node: Node = self while node.parent is not None: node = node.parent return node def _camera(self): """Find (and cache) the active Camera3D anywhere in the scene.""" from .nodes_3d.camera import Camera3D cam = self._cached_camera if cam is not None and cam.tree is not None: return cam cam = self._scene_root().find(Camera3D) self._cached_camera = cam return cam def _wind(self) -> tuple[float, float, float]: """Return ``(dir_x, dir_z, strength)`` from the scene WorldEnvironment.""" from .world_environment import WorldEnvironment env = self._scene_root().find(WorldEnvironment) if env is None: return (0.0, 0.0, 0.0) d = env.wind_direction return (float(d[0]), float(d[1]), float(env.wind_strength)) # -- per-frame ---------------------------------------------------------
[docs] def on_update(self, dt: float): # Translate the rain-facing dials onto the inherited emitter each frame # (single source of truth is the Rain API). The launch direction slants # with the wind and gravity adds a matching lateral drift, so the drops # read as wind-blown rather than falling straight down. wx, wz, ws = self._wind() slant = 0.15 * float(self.wind_response) * ws self.emission_radius = float(self.radius) self.direction = (wx * slant, -1.0, wz * slant) self.speed = float(self.fall_speed) drift = 0.5 * float(self.wind_response) * ws self.gravity = (wx * drift, -9.8, wz * drift) if self.follow_camera: cam = self._camera() if cam is not None: cp = cam.world_position self.world_position = (float(cp[0]), float(cp[1]) + float(self.height), float(cp[2])) self._gpu_process(dt)