"""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)