"""Shared water-surface wire packing (design D16, RM-E1/E1w).
Dependency-free (numpy + core math only, no Vulkan) so BOTH the desktop water
pass (``water_pass.py``) and the browser exporter (``simvx.web`` runtime, which
runs under Pyodide where ``vulkan`` is unavailable) pack a ``WaterSurface3D`` into
the identical std430 record. One canonical layout, mirrored by the WaterSurface
struct in ``water.vert`` / ``water.frag`` (desktop) and ``water3d.wgsl`` (web).
"""
from __future__ import annotations
from typing import Any
import numpy as np
__all__ = ["WATER_SURFACE_DTYPE", "WATER_SURFACE_STRIDE", "build_water_records", "pack_water_surface"]
# Per-surface std430 record mirrored by the WaterSurface struct in the water
# shaders (desktop GLSL + web WGSL). mat4 (64) + 7 vec4 (112) = 176 bytes.
WATER_SURFACE_DTYPE = np.dtype(
[
("model", np.float32, (16,)),
("shallow_colour", np.float32, (4,)),
("deep_colour", np.float32, (4,)), # rgb + depth_fade_distance
("foam_colour", np.float32, (4,)), # rgb + foam_amount
("wave0", np.float32, (4,)), # amplitude, steepness, wavelength, base_dir
("wave1", np.float32, (4,)), # spread, speed, normal_scroll, normal_strength
("shade", np.float32, (4,)), # refraction_strength, fresnel_power, roughness, opacity
("extent", np.float32, (4,)), # size_x, size_z, subdivisions, planar_reflection_tex (-1 = none)
]
)
WATER_SURFACE_STRIDE = WATER_SURFACE_DTYPE.itemsize # 176
[docs]
def pack_water_surface(
model: np.ndarray, mat: Any, subdivisions: int, size: tuple[float, float], reflection_tex: int = -1
) -> np.ndarray:
"""Pack one surface (row-major ``model`` + a ``WaterMaterial``) into a record.
``model`` is transposed to column-major for the shader std430 mat4.
``reflection_tex`` is the bindless index of a :class:`PlanarReflection3D`
capture feeding a true planar mirror (RM-E2), or ``-1`` for none (the shader
then keeps the cubemap/sky reflection, byte-identical to RM-E1 water).
"""
rec = np.zeros((), dtype=WATER_SURFACE_DTYPE)
rec["model"] = np.ascontiguousarray(model.T, dtype=np.float32).ravel() # column-major for the shader
rec["shallow_colour"] = (*mat.shallow_colour[:3], 0.0)
rec["deep_colour"] = (*mat.deep_colour[:3], float(mat.depth_fade_distance))
rec["foam_colour"] = (*mat.foam_colour[:3], float(mat.foam_amount))
rec["wave0"] = (
float(mat.wave_amplitude),
float(mat.wave_steepness),
float(mat.wave_length),
float(mat.wave_direction),
)
rec["wave1"] = (
float(mat.direction_spread),
float(mat.wave_speed),
float(mat.normal_scroll_speed),
float(mat.normal_strength),
)
rec["shade"] = (
float(mat.refraction_strength),
float(mat.fresnel_power),
float(mat.roughness),
float(mat.opacity),
)
rec["extent"] = (float(size[0]), float(size[1]), float(subdivisions), float(reflection_tex))
return rec
[docs]
def build_water_records(surfaces: list[Any]) -> tuple[np.ndarray, int]:
"""Pack a list of ``WaterSurface3D`` nodes into a contiguous record array.
Used by the web exporter (the desktop pass packs per-frame from its own
submission tuples via :func:`pack_water_surface`). Reads each node's world
transform live, so a moving surface repacks correctly every frame. Returns
``(records, count)``; ``records`` is empty when ``surfaces`` is empty.
"""
from simvx.core.math.matrices import mat4_from_trs
count = len(surfaces)
packed = np.zeros(count, dtype=WATER_SURFACE_DTYPE)
for i, node in enumerate(surfaces):
model = mat4_from_trs(node.world_position, node.world_rotation, node.world_scale)
reflection = getattr(node, "reflection", None)
reflection_tex = int(getattr(reflection, "texture", -1)) if reflection is not None else -1
packed[i] = pack_water_surface(
model, node.material, int(node.subdivisions), tuple(node.size), reflection_tex
)
return packed, count