GameTech/URP 渲染实战

案例 19.10平面反射:C# 反射相机 + RenderTexture高阶

目标:水面倒映出岸上的柱子、石头和木栈道,而不只是天空。

为什么天空反射不够

案例 19.5 的反射来自天空盒(或反射探针),它只知道“某个方向上很远的地方是什么颜色”,不知道水面附近有一根柱子。反射探针可以捕捉场景,但它是从探针位置看出去的立方体贴图,对于一个大平面上近处物体的倒影,位置会严重错位。对于水面、镜面地板这类大平面,最准确的实时做法是平面反射:再架一台相机,从水面下方“镜像”的位置往上看,把画面渲染到一张 RT 里,水面采样它。

镜像矩阵与斜裁剪

平面用 (n, d) 表示,平面上的点满足 n·p + d = 0。点 p 关于平面的镜像是

p′=p−2(n·p+d)n

写成 4×4 矩阵 R,反射相机的视图矩阵就是主相机视图矩阵乘以 R:worldToCameraMatrix = cam.worldToCameraMatrix * R。它的含义是“先把世界镜像一次,再用主相机去看”。这样渲染出来的画面里,倒影点恰好出现在主相机看到的对应水面像素位置上——所以水面直接用自己的屏幕 UV 采样反射 RT 即可,不需要任何额外的坐标变换。

还有两个必须处理的问题:

Assets/Cookbook/Ch19/PlanarReflection.cs(节选:mirror)
void SetupReflectionCamera(Camera cam)
{
    m_ReflCam.CopyFrom(cam);                 // 视场角、远近裁剪面、清屏方式等与主相机一致
    m_ReflCam.enabled = false;
    m_ReflCam.targetTexture = null;          // 由渲染请求临时指定
    m_ReflCam.cullingMask = reflectLayers;
    m_ReflCam.cameraType = CameraType.Reflection;
    m_ReflCam.useOcclusionCulling = false;

    Vector3 normal = transform.up;
    Vector3 pos = transform.position;
    Vector4 plane = new Vector4(normal.x, normal.y, normal.z, -Vector3.Dot(normal, pos));
    Matrix4x4 reflection = ReflectionMatrix(plane);

    // 相机位置也镜像过去:_WorldSpaceCameraPos、视线方向等都要基于镜像后的位置
    m_ReflCam.transform.position = reflection.MultiplyPoint(cam.transform.position);
    m_ReflCam.transform.rotation = Quaternion.LookRotation(
        Vector3.Reflect(cam.transform.forward, normal), Vector3.Reflect(cam.transform.up, normal));
    m_ReflCam.worldToCameraMatrix = cam.worldToCameraMatrix * reflection;

    // 斜裁剪(Oblique)投影:把近裁剪面换成水面,水面以下的东西不进反射
    Vector4 clipPlane = CameraSpacePlane(m_ReflCam.worldToCameraMatrix,
                                         pos - normal * clipPlaneOffset, normal);
    m_ReflCam.projectionMatrix = cam.CalculateObliqueMatrix(clipPlane);
}

// 关于平面 (n, d) 的镜像矩阵:p' = p - 2 (n·p + d) n
static Matrix4x4 ReflectionMatrix(Vector4 p)
{
    var m = Matrix4x4.identity;
    m.m00 = 1 - 2 * p.x * p.x;  m.m01 = -2 * p.x * p.y;
    m.m02 = -2 * p.x * p.z;     m.m03 = -2 * p.w * p.x;
    m.m10 = -2 * p.y * p.x;     m.m11 = 1 - 2 * p.y * p.y;
    m.m12 = -2 * p.y * p.z;     m.m13 = -2 * p.w * p.y;
    m.m20 = -2 * p.z * p.x;     m.m21 = -2 * p.z * p.y;
    m.m22 = 1 - 2 * p.z * p.z;  m.m23 = -2 * p.w * p.z;
    return m;   // 第四行保持 (0, 0, 0, 1)
}

// 把世界空间的平面变换到观察空间(CalculateObliqueMatrix 需要观察空间平面)
static Vector4 CameraSpacePlane(Matrix4x4 worldToCamera, Vector3 pos, Vector3 normal)
{
    Vector3 cpos = worldToCamera.MultiplyPoint(pos);
    Vector3 cnormal = worldToCamera.MultiplyVector(normal).normalized;
    return new Vector4(cnormal.x, cnormal.y, cnormal.z, -Vector3.Dot(cpos, cnormal));
}
setupReflectionCamera(cam) {
  // 平面:法线 = 水面物体的 up,经过物体原点
  const m = this.obj.matrix;
  const normal = vec3.normalize([m[4], m[5], m[6]]);
  const pos = [m[12], m[13], m[14]];
  const plane = [normal[0], normal[1], normal[2], -vec3.dot(normal, pos)];
  const reflection = reflectionMatrix(plane);

  // 视图矩阵 = 主相机视图 × 镜像:相机位置、_WorldSpaceCameraPos 都随之镜像
  const view = mat4.mul(cam.view, reflection);

  // 斜裁剪(Oblique)投影:把近裁剪面换成水面,水面以下的东西不进反射
  const clipPlane = cameraSpacePlane(view, vec3.sub(pos, vec3.scale(normal, this.clipPlaneOffset)), normal);
  const proj = calculateObliqueMatrix(cam.projection(), clipPlane);

  return {
    json: cam.json, view, projection: () => proj,
    fov: cam.fov, aspect: cam.aspect, near: cam.near, far: cam.far,
    orthographic: cam.orthographic, orthoSize: cam.orthoSize,
    clearFlags: cam.clearFlags, background: cam.background,
    cullingMask: this.reflectLayers,
    allowHDR: cam.allowHDR, allowMSAA: false,
    renderShadows: this.renderShadows, postProcessing: false, antialiasing: 0,
  };
}

URP 14 里怎么让一台相机渲染一次

这是平面反射在 URP 里最容易写错的地方。网上流传最广的写法来自 URP 早期版本:订阅 RenderPipelineManager.beginCameraRendering,在回调里调用 UniversalRenderPipeline.RenderSingleCamera(context, reflectionCamera)。在 URP 14.0.12 里,这个函数已经被标记为过时:

// 摘自 URP 14 Runtime/UniversalRenderPipeline.cs
[Obsolete("RenderSingleCamera is obsolete, please use RenderPipeline.SubmitRenderRequest with UniversalRenderer.SingleCameraRequest as RequestData type", false)]
public static void RenderSingleCamera(ScriptableRenderContext context, Camera camera)

提示里说的新方式是 RenderPipeline.SubmitRenderRequest(camera, request),请求类型是嵌套在 UniversalRenderPipeline 里的 SingleCameraRequest(提示文字里写的是 UniversalRenderer.SingleCameraRequest,但在 14.0.12 源码中这个类实际定义在 UniversalRenderPipeline 内部,以源码为准)。读 ProcessRenderRequests 的实现可以看到,它临时把相机的 targetTexture 换成请求里的 destination,依次调用 BeginContextRendering、BeginCameraRendering、渲染、EndCameraRendering、EndContextRendering,最后把 targetTexture 恢复原样。

那么,能不能把新 API 直接放进 beginCameraRendering 回调里,替换掉旧的 RenderSingleCamera?不能。拍摄本章配图时实测,在回调里调用 SubmitRenderRequest,Unity 会报错:

Recursive rendering is not supported in SRP (are you calling Camera.Render from within a render pipeline?).

原因是 beginCameraRendering 发生在 SRP 渲染循环内部,而 SubmitRenderRequest 本身要启动一次完整的渲染循环——这是一次递归渲染。旧的 RenderSingleCamera 能在回调里用,是因为它直接把命令录进主相机正在使用的 context,并不开启新的循环;但它已过时;本书在 URP 14 下试验这种写法时,程序能运行,但主相机水面的折射与焦散全部失效、反射画面也不正确(推测是两台相机共用同一个 context 时,相机相关的全局纹理与状态互相覆盖),因此不建议继续使用。

正确的做法是在渲染循环之外提交请求:运行时在 LateUpdate 里为主相机渲染一次反射(LateUpdate 在所有相机渲染之前执行,此时主相机的位置已经被 Update 里的逻辑更新完毕);编辑器拍图时,在主相机 Render() 之前手动调用一次。

Assets/Cookbook/Ch19/PlanarReflection.cs(节选:render)
/// 在渲染循环之外调用(LateUpdate、或者主相机 Render 之前)。
/// 不要在 beginCameraRendering 回调里调用它:回调发生在 SRP 渲染循环内部,再提交请求会报
/// “Recursive rendering is not supported in SRP”(本书拍摄时实测)。
public void RenderFor(Camera cam)
{
    if (m_Rendering) return;
    m_Rendering = true;
    EnsureResources(cam);
    SetupReflectionCamera(cam);

    var request = new UniversalRenderPipeline.SingleCameraRequest { destination = m_RT };
    if (RenderPipeline.SupportsRenderRequest(m_ReflCam, request))
    {
        // 镜像矩阵把左手系变成右手系,三角形绕序反了:临时反转剔除,否则只看得到物体的背面
        GL.invertCulling = true;
        RenderPipeline.SubmitRenderRequest(m_ReflCam, request);
        GL.invertCulling = false;
    }
    Shader.SetGlobalTexture(s_TexId, m_RT);
    m_Rendering = false;
}
renderFor(cam, time, dt) {
  if (this.rendering) return;
  this.rendering = true;
  const E = this.engine, gl = E.gl;
  const W = E.canvas.width, H = E.canvas.height;
  const w = Math.max(1, (W * this.resolutionScale) | 0), h = Math.max(1, (H * this.resolutionScale) | 0);
  // 反射 RT:主相机允许 HDR 就用浮点格式(C# 版的 DefaultHDR),自带深度,不开 MSAA
  const rt = E.rt('planarReflection', w, h, { color: cam.allowHDR ? 'RGBA16F' : 'SRGB8_ALPHA8', depth: 'DEPTH24_STENCIL8', samples: 1 });

  const reflCam = this.setupReflectionCamera(cam);
  // 用一个“只换了相机”的场景视图建 Frame:灯光、材质、物体都和主相机共用
  const view = Object.create(this.scene);
  view.camera = reflCam;
  const prevVP = E.prevViewProj;
  const frame = new Frame(E, view, w, h, time, dt);
  frame.requireDepth = false;    // requiresDepthOption = Off
  frame.requireOpaque = false;   // requiresColorOption = Off
  frame.setupCamera();
  frame.setupLights();
  frame.setupEnvironment();
  frame.renderShadows();         // reflCam.renderShadows = false 时只是把阴影关掉

  // 清屏(和主相机的清屏方式一致)
  frame.cameraTarget = rt;
  E.bindTarget(rt);
  gl.depthMask(true); gl.colorMask(true, true, true, true); gl.stencilMask(255);
  const bg = reflCam.clearFlags === 'SolidColor' ? reflCam.background : [0, 0, 0, 0];
  gl.clearColor(bg[0], bg[1], bg[2], bg[3]); gl.clearDepth(1); gl.clearStencil(0);
  gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT | gl.STENCIL_BUFFER_BIT);

  // 镜像矩阵把左手系变成右手系,三角形绕序反了:临时反转剔除(C# 版的 GL.invertCulling = true),
  // 否则只看得到物体的背面。引擎按物体的 flip 标志决定正面绕序,这里把它们临时取反
  const rs = this.scene.renderers;
  rs.forEach((o) => (o.flip = !o.flip));
  try {
    frame.renderOpaques();
    frame.renderSkybox();
    frame.renderTransparents();
  } finally {
    rs.forEach((o) => (o.flip = !o.flip));
  }

  E.prevViewProj = prevVP;
  E.setGlobal('_PlanarReflectionTex', rt.texture);
  this.rendering = false;
}

完整的组件:

Assets/Cookbook/Ch19/PlanarReflection.cs
// 案例 19.9:平面反射。用一台“镜像相机”从水面下方往上看,把画面渲染进 RenderTexture,
// 再设成全局纹理 _PlanarReflectionTex,水面 Shader 用屏幕 UV 采样它。
// URP 14 推荐的渲染方式:RenderPipeline.SubmitRenderRequest + UniversalRenderPipeline.SingleCameraRequest
// (UniversalRenderPipeline.RenderSingleCamera 在 14 里已标记为 Obsolete)。
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;

namespace Cookbook.Ch19
{
    [ExecuteAlways]
    public class PlanarReflection : MonoBehaviour
    {
        public enum UpdateMode
        {
            /// 在 LateUpdate 里为 Camera.main 提交渲染请求(SubmitRenderRequest,推荐)
            LateUpdate,
            /// 不自动渲染,由外部调用 RenderFor(camera)
            Manual,
        }

        public UpdateMode updateMode = UpdateMode.LateUpdate;
        [Tooltip("反射相机渲染哪些层。水面自己必须排除在外,否则会把自己画进反射里")]
        public LayerMask reflectLayers = ~(1 << 4);          // 4 = 内置的 Water 层
        [Range(0.25f, 1f)] public float resolutionScale = 0.5f;
        [Tooltip("裁剪平面相对水面的偏移,略低于水面可以避免岸边出现缝隙")]
        public float clipPlaneOffset = 0.05f;
        public bool renderShadows = false;

        static readonly int s_TexId = Shader.PropertyToID("_PlanarReflectionTex");
        Camera m_ReflCam;
        RenderTexture m_RT;
        bool m_Rendering;           // 防止重入

        void OnDisable()
        {
            if (m_ReflCam != null) DestroyImmediate(m_ReflCam.gameObject);
            if (m_RT != null) { m_RT.Release(); DestroyImmediate(m_RT); }
            m_ReflCam = null; m_RT = null;
        }

        void LateUpdate()
        {
            if (updateMode == UpdateMode.LateUpdate && Camera.main != null) RenderFor(Camera.main);
        }

        /// 在渲染循环之外调用(LateUpdate、或者主相机 Render 之前)。
        /// 不要在 beginCameraRendering 回调里调用它:回调发生在 SRP 渲染循环内部,再提交请求会报
        /// “Recursive rendering is not supported in SRP”(本书拍摄时实测)。
        public void RenderFor(Camera cam)
        {
            if (m_Rendering) return;
            m_Rendering = true;
            EnsureResources(cam);
            SetupReflectionCamera(cam);

            var request = new UniversalRenderPipeline.SingleCameraRequest { destination = m_RT };
            if (RenderPipeline.SupportsRenderRequest(m_ReflCam, request))
            {
                // 镜像矩阵把左手系变成右手系,三角形绕序反了:临时反转剔除,否则只看得到物体的背面
                GL.invertCulling = true;
                RenderPipeline.SubmitRenderRequest(m_ReflCam, request);
                GL.invertCulling = false;
            }
            Shader.SetGlobalTexture(s_TexId, m_RT);
            m_Rendering = false;
        }

        void EnsureResources(Camera cam)
        {
            int w = Mathf.Max(1, (int)(cam.pixelWidth * resolutionScale));
            int h = Mathf.Max(1, (int)(cam.pixelHeight * resolutionScale));
            if (m_RT == null || m_RT.width != w || m_RT.height != h)
            {
                if (m_RT != null) { m_RT.Release(); DestroyImmediate(m_RT); }
                var fmt = cam.allowHDR ? RenderTextureFormat.DefaultHDR : RenderTextureFormat.Default;
                m_RT = new RenderTexture(w, h, 24, fmt) { name = "_PlanarReflectionTex", hideFlags = HideFlags.DontSave };
                m_RT.Create();
            }
            if (m_ReflCam == null)
            {
                var go = new GameObject("Planar Reflection Camera") { hideFlags = HideFlags.HideAndDontSave };
                m_ReflCam = go.AddComponent<Camera>();
                m_ReflCam.enabled = false;           // 不参与正常的相机循环,只在我们提交请求时渲染
                var data = go.AddComponent<UniversalAdditionalCameraData>();
                data.renderPostProcessing = false;   // 后处理留给主相机统一做
                data.requiresColorOption = CameraOverrideOption.Off;
                data.requiresDepthOption = CameraOverrideOption.Off;
                data.SetRenderer(0);
            }
            m_ReflCam.GetComponent<UniversalAdditionalCameraData>().renderShadows = renderShadows;
        }

        void SetupReflectionCamera(Camera cam)
        {
            m_ReflCam.CopyFrom(cam);                 // 视场角、远近裁剪面、清屏方式等与主相机一致
            m_ReflCam.enabled = false;
            m_ReflCam.targetTexture = null;          // 由渲染请求临时指定
            m_ReflCam.cullingMask = reflectLayers;
            m_ReflCam.cameraType = CameraType.Reflection;
            m_ReflCam.useOcclusionCulling = false;

            Vector3 normal = transform.up;
            Vector3 pos = transform.position;
            Vector4 plane = new Vector4(normal.x, normal.y, normal.z, -Vector3.Dot(normal, pos));
            Matrix4x4 reflection = ReflectionMatrix(plane);

            // 相机位置也镜像过去:_WorldSpaceCameraPos、视线方向等都要基于镜像后的位置
            m_ReflCam.transform.position = reflection.MultiplyPoint(cam.transform.position);
            m_ReflCam.transform.rotation = Quaternion.LookRotation(
                Vector3.Reflect(cam.transform.forward, normal), Vector3.Reflect(cam.transform.up, normal));
            m_ReflCam.worldToCameraMatrix = cam.worldToCameraMatrix * reflection;

            // 斜裁剪(Oblique)投影:把近裁剪面换成水面,水面以下的东西不进反射
            Vector4 clipPlane = CameraSpacePlane(m_ReflCam.worldToCameraMatrix,
                                                 pos - normal * clipPlaneOffset, normal);
            m_ReflCam.projectionMatrix = cam.CalculateObliqueMatrix(clipPlane);
        }

        // 关于平面 (n, d) 的镜像矩阵:p' = p - 2 (n·p + d) n
        static Matrix4x4 ReflectionMatrix(Vector4 p)
        {
            var m = Matrix4x4.identity;
            m.m00 = 1 - 2 * p.x * p.x;  m.m01 = -2 * p.x * p.y;
            m.m02 = -2 * p.x * p.z;     m.m03 = -2 * p.w * p.x;
            m.m10 = -2 * p.y * p.x;     m.m11 = 1 - 2 * p.y * p.y;
            m.m12 = -2 * p.y * p.z;     m.m13 = -2 * p.w * p.y;
            m.m20 = -2 * p.z * p.x;     m.m21 = -2 * p.z * p.y;
            m.m22 = 1 - 2 * p.z * p.z;  m.m23 = -2 * p.w * p.z;
            return m;   // 第四行保持 (0, 0, 0, 1)
        }

        // 把世界空间的平面变换到观察空间(CalculateObliqueMatrix 需要观察空间平面)
        static Vector4 CameraSpacePlane(Matrix4x4 worldToCamera, Vector3 pos, Vector3 normal)
        {
            Vector3 cpos = worldToCamera.MultiplyPoint(pos);
            Vector3 cnormal = worldToCamera.MultiplyVector(normal).normalized;
            return new Vector4(cnormal.x, cnormal.y, cnormal.z, -Vector3.Dot(cpos, cnormal));
        }
    }
}
// 案例 19.9:平面反射(JS 版,对应 Ch19/PlanarReflection.cs)。
// 用一台“镜像相机”从水面下方往上看,把画面渲染进一张 RenderTexture,
// 再设成全局纹理 _PlanarReflectionTex,水面 Shader 用屏幕 UV 采样它。
//
// C# 版用 RenderPipeline.SubmitRenderRequest 让 URP 为反射相机完整渲染一次;
// 网页版在 update() 里(主相机渲染之前)用引擎的 Frame 为镜像相机画一遍:不透明 → 天空盒 → 半透明。
// 和 C# 版一样:不画阴影、不做后处理、不生成深度/不透明贴图。
import { Frame } from '../engine/pipeline.js';
import { mat4, vec3 } from '../engine/math.js';
import { layerMask } from '../engine/features.js';

export default class PlanarReflection {
  constructor(engine, json, obj, scene) {
    this.engine = engine;
    this.obj = obj;            // 水面物体
    this.scene = scene;
    const d = json.data || {};
    this.enabled = json.enabled !== false && d.m_Enabled !== false;
    this.reflectLayers = layerMask(d.reflectLayers ?? ~(1 << 4));   // 4 = 内置的 Water 层
    this.resolutionScale = d.resolutionScale ?? 0.5;
    this.clipPlaneOffset = d.clipPlaneOffset ?? 0.05;
    this.renderShadows = !!d.renderShadows;
    this.rendering = false;   // 防止重入
  }

  // C# 版的 LateUpdate / 拍图时的 RenderFor(Camera.main):每帧在主相机渲染之前调用
  update(time, dt) {
    if (!this.enabled || !this.obj.active || !this.scene.camera) return;
    this.renderFor(this.scene.camera, time, dt);
  }

  renderFor(cam, time, dt) {
    if (this.rendering) return;
    this.rendering = true;
    const E = this.engine, gl = E.gl;
    const W = E.canvas.width, H = E.canvas.height;
    const w = Math.max(1, (W * this.resolutionScale) | 0), h = Math.max(1, (H * this.resolutionScale) | 0);
    // 反射 RT:主相机允许 HDR 就用浮点格式(C# 版的 DefaultHDR),自带深度,不开 MSAA
    const rt = E.rt('planarReflection', w, h, { color: cam.allowHDR ? 'RGBA16F' : 'SRGB8_ALPHA8', depth: 'DEPTH24_STENCIL8', samples: 1 });

    const reflCam = this.setupReflectionCamera(cam);
    // 用一个“只换了相机”的场景视图建 Frame:灯光、材质、物体都和主相机共用
    const view = Object.create(this.scene);
    view.camera = reflCam;
    const prevVP = E.prevViewProj;
    const frame = new Frame(E, view, w, h, time, dt);
    frame.requireDepth = false;    // requiresDepthOption = Off
    frame.requireOpaque = false;   // requiresColorOption = Off
    frame.setupCamera();
    frame.setupLights();
    frame.setupEnvironment();
    frame.renderShadows();         // reflCam.renderShadows = false 时只是把阴影关掉

    // 清屏(和主相机的清屏方式一致)
    frame.cameraTarget = rt;
    E.bindTarget(rt);
    gl.depthMask(true); gl.colorMask(true, true, true, true); gl.stencilMask(255);
    const bg = reflCam.clearFlags === 'SolidColor' ? reflCam.background : [0, 0, 0, 0];
    gl.clearColor(bg[0], bg[1], bg[2], bg[3]); gl.clearDepth(1); gl.clearStencil(0);
    gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT | gl.STENCIL_BUFFER_BIT);

    // 镜像矩阵把左手系变成右手系,三角形绕序反了:临时反转剔除(C# 版的 GL.invertCulling = true),
    // 否则只看得到物体的背面。引擎按物体的 flip 标志决定正面绕序,这里把它们临时取反
    const rs = this.scene.renderers;
    rs.forEach((o) => (o.flip = !o.flip));
    try {
      frame.renderOpaques();
      frame.renderSkybox();
      frame.renderTransparents();
    } finally {
      rs.forEach((o) => (o.flip = !o.flip));
    }

    E.prevViewProj = prevVP;
    E.setGlobal('_PlanarReflectionTex', rt.texture);
    this.rendering = false;
  }

  setupReflectionCamera(cam) {
    // 平面:法线 = 水面物体的 up,经过物体原点
    const m = this.obj.matrix;
    const normal = vec3.normalize([m[4], m[5], m[6]]);
    const pos = [m[12], m[13], m[14]];
    const plane = [normal[0], normal[1], normal[2], -vec3.dot(normal, pos)];
    const reflection = reflectionMatrix(plane);

    // 视图矩阵 = 主相机视图 × 镜像:相机位置、_WorldSpaceCameraPos 都随之镜像
    const view = mat4.mul(cam.view, reflection);

    // 斜裁剪(Oblique)投影:把近裁剪面换成水面,水面以下的东西不进反射
    const clipPlane = cameraSpacePlane(view, vec3.sub(pos, vec3.scale(normal, this.clipPlaneOffset)), normal);
    const proj = calculateObliqueMatrix(cam.projection(), clipPlane);

    return {
      json: cam.json, view, projection: () => proj,
      fov: cam.fov, aspect: cam.aspect, near: cam.near, far: cam.far,
      orthographic: cam.orthographic, orthoSize: cam.orthoSize,
      clearFlags: cam.clearFlags, background: cam.background,
      cullingMask: this.reflectLayers,
      allowHDR: cam.allowHDR, allowMSAA: false,
      renderShadows: this.renderShadows, postProcessing: false, antialiasing: 0,
    };
  }
}

// 关于平面 (n, d) 的镜像矩阵:p' = p - 2 (n·p + d) n(列主序,下标 m[列*4+行])
function reflectionMatrix(p) {
  const [x, y, z, w] = p;
  return new Float32Array([
    1 - 2 * x * x, -2 * y * x, -2 * z * x, 0,
    -2 * x * y, 1 - 2 * y * y, -2 * z * y, 0,
    -2 * x * z, -2 * y * z, 1 - 2 * z * z, 0,
    -2 * w * x, -2 * w * y, -2 * w * z, 1,
  ]);
}

// 把世界空间的平面变换到观察空间(斜裁剪需要观察空间平面)
function cameraSpacePlane(worldToCamera, pos, normal) {
  const cpos = mat4.transformPoint(worldToCamera, pos);
  const cn = vec3.normalize(mat4.transformDir(worldToCamera, normal));
  return [cn[0], cn[1], cn[2], -vec3.dot(cpos, cn)];
}

// Unity 的 Camera.CalculateObliqueMatrix(Lengyel 的斜裁剪近平面):替换投影矩阵第 3 行
function calculateObliqueMatrix(projection, c) {
  const P = new Float32Array(projection);
  const g = (r, col) => P[col * 4 + r];
  const sgn = (v) => (v > 0 ? 1 : v < 0 ? -1 : 0);
  const q = [(sgn(c[0]) + g(0, 2)) / g(0, 0), (sgn(c[1]) + g(1, 2)) / g(1, 1), -1, (1 + g(2, 2)) / g(2, 3)];
  const s = 2 / (c[0] * q[0] + c[1] * q[1] + c[2] * q[2] + c[3] * q[3]);
  P[0 * 4 + 2] = c[0] * s;
  P[1 * 4 + 2] = c[1] * s;
  P[2 * 4 + 2] = c[2] * s + 1;
  P[3 * 4 + 2] = c[3] * s;
  return P;
}

关键细节

在本书的拍摄脚本里,平面反射是这样触发的(摄影棚没有 LateUpdate,所以先给主相机挂一个临时 RT 确定分辨率与宽高比,手动渲染一次反射,再交给拍摄流程):

var pr = water.AddComponent<PlanarReflection>();
pr.updateMode = PlanarReflection.UpdateMode.Manual;
var tmp = RenderTexture.GetTemporary(w, h, 24);
s.Cam.targetTexture = tmp; s.Cam.aspect = (float)w / h;
pr.RenderFor(s.Cam);                 // 在主相机渲染之前、渲染循环之外
s.Cam.targetTexture = null;
RenderTexture.ReleaseTemporary(tmp);
图 19-14低机位对比。左:天空反射,水面只映出天空;右:平面反射,木栈道、石柱在水面上投下与之对齐的倒影,倒影被波纹扰动、随菲涅尔在近处变淡。

屏幕空间反射(SSR)简介

平面反射的代价是“整个场景再渲染一遍”,而且只适用于一个平面——起伏很大的海面就不准确了。另一种思路是屏幕空间反射(Screen Space Reflection):沿反射方向在深度图里做光线步进,找到交点后从当前画面取颜色。它不需要额外的相机,适用于任意形状的表面,但只能反射屏幕上已经存在的东西:画面边缘、被遮挡的物体、相机背后的物体都反射不出来,需要退回到反射探针。URP 14 没有内置 SSR,第 28 章会介绍它的原理。实际项目里,水面常见的组合是:平面反射(或 SSR)负责近处物体,反射探针/天空负责其余部分,用菲涅尔和距离混合。