GameTech/URP 渲染实战
源码 / behaviours/Cookbook.Ch19.PlanarReflection.js
behaviours/Cookbook.Ch19.PlanarReflection.js
// 案例 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;
}