Source code for simvx.core.water

"""WaterSurface3D + WaterMaterial: a built-in animated water plane (design D16).

Water is a dedicated built-in render pass (its own GLSL + WGSL shader pair,
like the tilemap / grid passes), NOT a ShaderMaterial. A :class:`WaterSurface3D`
node places a horizontal grid whose vertices are displaced by a sum of Gerstner
waves in the vertex stage (direction + strength taken from the FrameGlobals wind
slot, phase from FrameGlobals time), and whose fragment stage refracts the scene
behind it (sampling the scene-colour/depth copy), fades to a deep-water tint with
depth, mixes a Fresnel reflection from the environment, and adds shore foam where
the water meets geometry.

The appearance knobs live on the :class:`WaterMaterial` value object (mirrors
``FogMaterial`` / ``Material``); geometry (extent + tessellation) lives on the
node. The presence of any visible ``WaterSurface3D`` in the scene is what makes
the desktop renderer split the opaque/transparent pass to produce the scene
colour/depth copy the refraction reads; with no water surface the split never
happens and the frame is byte-identical (zero-cost when unused).
"""

from __future__ import annotations

from .descriptors import Property
from .math.types import Vec3
from .nodes_3d.node3d import Node3D

__all__ = ["WaterMaterial", "WaterSurface3D"]


[docs] class WaterMaterial: """Appearance of a :class:`WaterSurface3D` (a plain value object). Assign one to ``WaterSurface3D.material``; the renderer reads these fields each frame. All colours are linear RGB. Wave *direction* and *strength* are modulated by the scene ``WorldEnvironment`` wind (FrameGlobals): with no wind the base direction/amplitude below drive the motion, so water animates out of the box. Attributes: shallow_colour: Water tint over shallow geometry (RGB, near the shore). deep_colour: Water tint over deep geometry (RGB, far from the shore). depth_fade_distance: World distance over which shallow blends to deep and transparency ramps to opaque. wave_amplitude: Base peak height of the Gerstner sum, in world units. wave_steepness: Gerstner steepness 0..1 (0 rolling swell, 1 sharp crests). wave_length: Base wavelength of the longest Gerstner wave, world units. wave_direction: Base travel direction, radians in the XZ plane (used when the wind strength is ~0; otherwise wind direction leads). direction_spread: Angular fan of the four Gerstner components, radians. wave_speed: Multiplier on wave phase speed. normal_scroll_speed: Scroll rate of the two detail-ripple normal layers. normal_strength: Intensity of the detail ripples added to the wave normal. foam_colour: Shore-foam colour (RGB). foam_amount: Depth band (world units) over which shore foam appears. refraction_strength: Screen-space refraction offset scale (world units). fresnel_power: Fresnel exponent for the reflection/refraction blend. roughness: Surface roughness for the environment reflection sample. opacity: Overall surface opacity ceiling (deep water) 0..1. """ __slots__ = ( "shallow_colour", "deep_colour", "depth_fade_distance", "wave_amplitude", "wave_steepness", "wave_length", "wave_direction", "direction_spread", "wave_speed", "normal_scroll_speed", "normal_strength", "foam_colour", "foam_amount", "refraction_strength", "fresnel_power", "roughness", "opacity", ) def __init__( self, *, shallow_colour: tuple[float, float, float] = (0.10, 0.38, 0.42), deep_colour: tuple[float, float, float] = (0.02, 0.09, 0.16), depth_fade_distance: float = 3.0, wave_amplitude: float = 0.18, wave_steepness: float = 0.55, wave_length: float = 6.0, wave_direction: float = 0.6, direction_spread: float = 0.8, wave_speed: float = 1.0, normal_scroll_speed: float = 0.05, normal_strength: float = 0.5, foam_colour: tuple[float, float, float] = (0.9, 0.95, 1.0), foam_amount: float = 0.6, refraction_strength: float = 0.35, fresnel_power: float = 5.0, roughness: float = 0.04, opacity: float = 0.92, ) -> None: self.shallow_colour = shallow_colour self.deep_colour = deep_colour self.depth_fade_distance = depth_fade_distance self.wave_amplitude = wave_amplitude self.wave_steepness = wave_steepness self.wave_length = wave_length self.wave_direction = wave_direction self.direction_spread = direction_spread self.wave_speed = wave_speed self.normal_scroll_speed = normal_scroll_speed self.normal_strength = normal_strength self.foam_colour = foam_colour self.foam_amount = foam_amount self.refraction_strength = refraction_strength self.fresnel_power = fresnel_power self.roughness = roughness self.opacity = opacity
[docs] def __repr__(self) -> str: return f"<WaterMaterial waves={self.wave_amplitude:.2f} refraction={self.refraction_strength:.2f}>"
[docs] class WaterSurface3D(Node3D): """A horizontal animated water plane rendered by the built-in water pass. Place the node at the water level: its world XZ plane (local +Y up) is the still surface, extended to ``size`` world units and tessellated into ``subdivisions`` × ``subdivisions`` cells for the Gerstner displacement. The fragment stage refracts and depth-fades against the scene behind it, so put submerged geometry below the plane and a directional light + sky above. Example:: water = WaterSurface3D(position=(0, 0, 0), size=(40, 40)) water.material = WaterMaterial(wave_amplitude=0.25) scene.add_child(water) Properties: size: Water plane extent in world units (x, z). subdivisions: Grid resolution per axis (higher = smoother crests). visible: Standard Node visibility; a hidden surface neither draws nor triggers the scene colour/depth copy. """ size = Property((20.0, 20.0), hint="Water plane extent in world units (x, z)", group="Water") subdivisions = Property(128, range=(2, 512), hint="Grid tessellation per axis", group="Water") gizmo_colour = Property((0.2, 0.5, 0.9, 0.5)) def __init__(self, material: WaterMaterial | None = None, reflection=None, **kwargs): super().__init__(**kwargs) self._material: WaterMaterial = material if material is not None else WaterMaterial() self._reflection = reflection @property def material(self) -> WaterMaterial: """The :class:`WaterMaterial` controlling this surface's appearance.""" return self._material
[docs] @material.setter def material(self, mat: WaterMaterial | None) -> None: self._material = mat if mat is not None else WaterMaterial()
@property def reflection(self): """Optional :class:`PlanarReflection3D` feeding a true planar mirror (RM-E2). When set, the surface samples this node's live capture as its reflection (a real mirror of the world above the water) instead of the environment cubemap, blended against the refraction below by Fresnel and rippled by the wave normal. The node must sit on the water plane (same world Y, its world +Y as the plane normal), exactly like a mirror floor; drop it in as a child and assign it here. ``None`` keeps the cubemap/sky reflection, so a surface with no reflection is byte-identical to the RM-E1 water. """ return self._reflection
[docs] @reflection.setter def reflection(self, node) -> None: self._reflection = node
[docs] def get_gizmo_lines(self) -> list[tuple[Vec3, Vec3]]: """Editor gizmo: the still-surface rectangle at the node's world plane.""" sx, sz = float(self.size[0]) * 0.5, float(self.size[1]) * 0.5 p = self.world_position corners = [ Vec3(p.x - sx, p.y, p.z - sz), Vec3(p.x + sx, p.y, p.z - sz), Vec3(p.x + sx, p.y, p.z + sz), Vec3(p.x - sx, p.y, p.z + sz), ] return [(corners[i], corners[(i + 1) % 4]) for i in range(4)]