Source code for simvx.core.planar_reflection

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