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