"""Planar reflections: the main scene mirrored across a plane (design RM-B4).
Built on :class:`~simvx.core.RenderView`: a :class:`PlanarReflection3D` node
renders the MAIN scene through a camera mirrored across its own local XZ plane
(the node's +Y axis is the plane normal) into an offscreen texture. A floor,
mirror or water Material samples that texture with a projective UV derived
from the fragment's clip position, so pass the node straight to
``Material(albedo_map=planar_reflection)`` and the surface shows the mirrored
world.
Two building blocks, usable separately:
* :func:`planar_reflection_camera`: reflect any ``Camera3D`` across a world
plane, returning a mirrored camera whose projection carries an oblique
near-clip plane (Lengyel) so nothing behind the reflection plane is drawn.
* :class:`PlanarReflection3D`: the node that owns a mirrored camera, keeps it
in sync with the active main camera every frame (the render backends call
:meth:`~PlanarReflection3D.sync_reflection` while preparing offscreen
targets), and publishes the reflection texture via ``RenderView.texture``.
The mirrored camera is built as a PROPER rotation (a reflected orthonormal
basis has determinant -1, which a ``Node3D`` cannot represent): reflecting the
camera's forward and up vectors and rebuilding the basis flips the camera-space
X axis relative to the true mirror image. The material shaders compensate by
mirroring the horizontal projective UV, so the composition is exact for points
on the reflection plane, which is precisely the surface being shaded.
Pipelined render mode defers RenderView capture entirely (probe-capture
precedent), so a PlanarReflection3D freezes there like any RenderView.
"""
import numpy as np
from .descriptors import Property
from .nodes_3d.camera import Camera3D
from .render_view import RenderView
__all__ = ["PlanarReflection3D", "planar_reflection_camera"]
def _reflect_direction(d: np.ndarray, n: np.ndarray) -> np.ndarray:
return d - 2.0 * float(np.dot(d, n)) * n
[docs]
def planar_reflection_camera(
plane: tuple[float, float, float, float],
main_camera: Camera3D,
*,
into: Camera3D | None = None,
) -> Camera3D:
"""Return a ``Camera3D`` mirroring *main_camera* across a world plane.
The result views the scene from the far side of the plane (position,
forward and up reflected) with *main_camera*'s fov/near/far, and carries
``clip_plane = plane`` so its projection obliquely clips everything behind
the reflection plane (:func:`~simvx.core.math.oblique_near_plane`).
Args:
plane: World-space plane ``(nx, ny, nz, d)``, equation ``n . x + d = 0``
with unit ``n``; the kept side is ``n . x + d > 0``.
main_camera: The camera to mirror (typically the scene's active one).
into: Optional existing camera to update in place (per-frame reuse
without allocation). A new standalone ``Camera3D`` when ``None``.
Returns:
The mirrored camera (``into`` when given). Its basis is a proper
rotation: the rendered image equals the true mirror image flipped
horizontally, which the planar-reflection material samples away by
mirroring the horizontal projective UV.
"""
n = np.asarray(plane[:3], dtype=np.float64)
n = n / np.linalg.norm(n)
d = float(plane[3])
cam = into if into is not None else Camera3D(name="PlanarReflectionCamera")
pos = np.asarray(main_camera.world_position, dtype=np.float64)
cam.position = tuple(pos - 2.0 * (float(np.dot(n, pos)) + d) * n)
forward = _reflect_direction(np.asarray(main_camera.forward, dtype=np.float64), n)
up = _reflect_direction(np.asarray(main_camera.up, dtype=np.float64), n)
cam.face_along(tuple(forward), up=tuple(up))
cam.fov = float(main_camera.fov)
cam.near = float(main_camera.near)
cam.far = float(main_camera.far)
cam.clip_plane = (float(n[0]), float(n[1]), float(n[2]), d)
return cam
[docs]
class PlanarReflection3D(RenderView):
"""Mirrors the main scene across this node's local XZ plane into a texture.
Place the node ON the reflective surface: its world position is a point on
the reflection plane and its world +Y axis is the plane normal. Every frame
the backends re-mirror the scene's active camera across that plane (with an
oblique near clip, so nothing below the surface leaks into the reflection)
and render the main tree from it, sized to the main viewport times
``resolution_scale`` so the projective UV stays aligned at any window size.
Sample the reflection by passing the node as a Material albedo::
water = MeshInstance3D(
mesh=Mesh.plane(...),
material=Material(albedo_map=reflection, unlit=True),
)
A Material whose ``albedo_map`` is a PlanarReflection3D samples the texture
with a mirrored projective UV from the fragment's clip position (not the
mesh UV), which is exact for fragments lying on the reflection plane.
Properties:
resolution_scale: Reflection resolution as a fraction of the main
viewport (0.05 to 1.0). Half resolution by default: reflections
tolerate softness well and the pass renders the whole scene again.
clip_bias: World-space offset of the oblique clip plane along the
normal. Keeps the reflective surface itself (and z-fighting slivers
on it) out of its own reflection.
``RenderView.camera`` is unused (the mirrored camera is internal and
derived); ``size`` is driven from ``resolution_scale`` each frame.
"""
_is_planar_reflection: bool = True
resolution_scale = Property(0.5, range=(0.05, 1.0), hint="Reflection resolution as a fraction of the main viewport")
clip_bias = Property(0.02, hint="Clip-plane offset along the surface normal (keeps the surface out)")
def __init__(self, name="PlanarReflection3D", **kwargs):
super().__init__(name=name, **kwargs)
# Standalone mirrored camera: an implementation detail, never added to
# the tree (so it can neither be picked as the active camera nor be
# serialised as scene content). Parentless, so local == world transform.
self._mirror_camera = Camera3D(name="_PlanarReflectionCamera")
self._synced = False
# (structure_version, cameras) discovery cache for the main-camera pick.
self._camera_cache: tuple[int, tuple[Camera3D, ...]] = (-1, ())
[docs]
def resolve_camera(self) -> Camera3D | None:
"""The internal mirrored camera; ``None`` until first synced."""
return self._mirror_camera if self._synced else None
[docs]
def world_plane(self) -> tuple[float, float, float, float]:
"""This node's reflection plane in world space, ``(nx, ny, nz, d)``.
The plane passes through the node's world position with the node's
world +Y axis as its (unit) normal; ``n . x + d = 0``.
"""
n = np.asarray(self.up, dtype=np.float64)
n = n / np.linalg.norm(n)
d = -float(np.dot(n, np.asarray(self.world_position, dtype=np.float64)))
return (float(n[0]), float(n[1]), float(n[2]), d)
[docs]
def sync_reflection(self, main_viewport_size: tuple[float, float]) -> bool:
"""Re-mirror the active main camera across this node's plane.
Called by the render backends while preparing offscreen targets (after
all node updates, before any offscreen render). Resizes the target to
``main_viewport_size * resolution_scale`` (the projective UV requires
the reflection aspect to match the main viewport) and rebuilds the
internal mirrored camera. Returns ``False`` (view skipped, one-time
warning in the manager) when no main camera exists.
"""
camera = self._main_camera()
if camera is None:
self._synced = False
return False
w, h = main_viewport_size
size = (max(int(round(w * self.resolution_scale)), 1), max(int(round(h * self.resolution_scale)), 1))
if tuple(self.size) != size:
self.size = size
nx, ny, nz, d = self.world_plane()
planar_reflection_camera((nx, ny, nz, d), camera, into=self._mirror_camera)
# Bias the oblique clip toward the kept side: the reflective surface
# itself sits exactly on the plane and must not render into its own
# reflection (it samples the texture being written).
self._mirror_camera.clip_plane = (nx, ny, nz, d - float(self.clip_bias))
self._synced = True
return True
def _main_camera(self) -> Camera3D | None:
"""The camera the main pass will render with this frame.
Mirrors the scene adapters' pick: an explicit tree camera override
(editor preview) wins, else the first visible ``Camera3D`` in tree
order. The camera list is cached by tree structure version; visibility
is re-checked every call.
"""
tree = self.tree
if tree is None or tree.root is None:
return None
override = getattr(tree, "_render_camera_override", None)
if isinstance(override, Camera3D):
return override
version = int(getattr(tree, "_structure_version", 0))
if self._camera_cache[0] != version:
# Same stack walk (and therefore the same visit order) as the
# adapters' node collection, so the pick matches the main pass.
found: list[Camera3D] = []
stack = [tree.root]
while stack:
node = stack.pop()
if isinstance(node, Camera3D):
found.append(node)
stack.extend(node.children)
self._camera_cache = (version, tuple(found))
for camera in self._camera_cache[1]:
if camera._visible_in_hierarchy and camera is not self._mirror_camera:
return camera
return None