从“没有阴影“到“画面完美“:Babylon.js 阴影方案的六次逼近
2026/7/30 9:36:37 网站建设 项目流程

引子:一个会"骗人"的 Bug

我们的系统分编辑器与运行时两端,共享同一份场景存档。某天发现一个诡异现象:编辑器里阴影一切正常,运行时加载同一份.c3d存档后,地面上干干净净——阴影消失了。

接下来的排查走了一条经典弯路:给ShadowGenerator、光源、材质、渲染管线到处埋日志,验证了 caster 列表、阴影贴图尺寸、bias、PCF、相机远近裁剪面……一切配置全部正确,但阴影就是没有。

直到有人把相机转了 90 度——阴影一直在那里,只是投向了画面之外。

这个教训奠定了整个优化过程的基调:阴影问题的现象和根因往往不在同一层。此后每一次"修复",都是先找到真正的因果层,再动手。这篇文章完整记录六次逼近过程,以及每个阶段沉淀下来的核心代码。

第一幕:两份存档角度,两套解读公式

阴影"消失"的真相是:编辑器和运行时用不同的数学公式把存档里的太阳角度(angleH, angleV)还原成方向向量。

编辑器端用的是自定义球坐标(偏航零点在 +X 轴):

// 编辑器的约定:yaw = atan2(d.z, d.x),pitch = asin(-d.y / |d|) d = (cosV·cosH, -sinV, cosV·sinH)

运行时代码却是这样写的:

// 运行时的实现:把 (angleV, angleH) 当欧拉角旋转 (0,0,1) const rotYawPitchRoll = Matrix.RotationYawPitchRoll(y, x, z); return Vector3.TransformCoordinates(new Vector3(0, 0, 1), rotYawPitchRoll); // 展开后:d = (sinH, -cosH·sinV, cosH·cosV) —— 偏航零点在 +Z 轴!

两套公式对同一份角度的还原结果相差 90° 偏航;更糟的是运行时公式里俯仰分量被cosH调制,当存档偏航角约 122° 时cosH < 0,太阳方向直接翻转为朝上照——阴影全部投向天空。

修复策略:不是改某一端,而是把转换收敛为共享函数(Shared/TScripts/DirectionAngles.ts),两端只准调用、禁止各自实现:

import { Vector3 } from "@babylonjs/core"; /** * 偏航角(yaw)/俯仰角(pitch) ↔ 单位方向向量 的互逆转换。 * 约定(存档字段的唯一解释标准): * yaw —— XZ 水平面内,零点 +X 轴,向 +Z 轴增大 * pitch —— 水平面为零点,向下为正(光照通常朝下) * 单位 —— 弧度 */ export function yawPitchToDirection(yaw: number, pitch: number): Vector3 { return new Vector3( Math.cos(pitch) * Math.cos(yaw), // x:先算水平圆,再乘 cos(yaw) 投影到 +X 基准 -Math.sin(pitch), // y:向下为正,故取负 Math.cos(pitch) * Math.sin(yaw) // z:水平圆的另一分量 ); } export function directionToYawPitch(dir: Vector3): { yaw: number; pitch: number } { const len = dir.length(); if (len === 0) { return { yaw: 0, pitch: 0 }; // 零向量无方向,返回默认角避免 NaN } // 浮点误差可能让比值略超 [-1,1],clamp 保护 asin const sinPitch = Math.min(1, Math.max(-1, -dir.y / len)); return { yaw: Math.atan2(dir.z, dir.x), // 与正向公式的零点约定严格互逆 pitch: Math.asin(sinPitch), }; }

教训一:多端共享的数据,其解释函数必须单一事实来源(SSOT)。这次事故的本质不是"公式写错",而是"同一约定有两份实现"。

第二幕:两端阴影参数各唱各的调

方向修好后,又发现两端阴影"质感"不同:编辑器是锐利硬边,运行时边缘发虚。排查发现两端的ShadowGenerator是各自手工配置的:

编辑器运行时

贴图

2048

2048

过滤

无(硬边锯齿)

PCF(柔化)

bias

0

0.0005

既然第一次事故是"两份实现",这次顺手把阴影生成器也收敛成共享函数createSunShadowGenerator,并顺手把贴图提到 4096。教训二:一致性修复要趁早变成结构性约束——共享函数一旦存在,后续所有升级都只改一处。

第三幕:贴地的盒子,悬浮的影子

统一参数后,新现象:盒子明明贴着地面,影子却整体平移了一段距离(peter-panning)。

根因藏在一个单位换算里。bias不是世界单位,而是归一化深度单位:

世界偏移 = bias × (shadowMaxZ − shadowMinZ)

而两端都没设置过shadowMinZ/shadowMaxZ——Babylon 此时默认沿用主相机的 near/far(远裁剪面 10000 米)。代入:0.0005 × 约10000 ≈ 数米的偏移。4096 的贴图分辨率对此完全无能为力,因为偏移发生在深度维度,不是 XY 分辨率维度。

修复只有一行,开启按 caster 包围盒自动收紧 Z 范围:

sunLight.autoCalcShadowZBounds = true; // z 跨度:千米级 → 场景实际尺度

Z 跨度收紧约百倍后,bias 的等效世界偏移降到厘米级,悬浮消失。教训三:阴影参数要先做量纲分析——归一化参数的实际效果取决于它背后的映射区间。

第四幕:按下悬浮,冒起条纹

悬浮修好的同时,地面和斜面上出现了明暗相间的条纹(shadow acne,自遮挡粉刺)。这不是新 bug,而是一直存在、只是被过大的 bias 掩盖了——之前米级的偏移把接收面深度远远"推离"了 shadow map 里自己的深度,acne 被误伤式地治好了,代价是悬浮。

对治 acne 的正确工具是normalBias:它沿接收面法线方向偏移采样点(世界单位),专门处理掠射角表面的自遮挡,且偏移方向不在光照方向上,不会重新引入悬浮:

shadowGenerator.normalBias = 0.02; // 初值:米级场景的常用起点

实践中 0.02 不够,最终调到 0.2 才压住地面条纹。这个数字偏大,但它其实暴露了下一个问题——normalBias 的合理量级 ≈texel世界尺寸 / tan(光与表面夹角),0.2 说明 texel 太粗了。

第五幕:撞上单级贴图的物理上限

当地面放大 10 倍后,阴影瞬间变模糊,地面爆发大面积同向平行条纹。这是单级正交阴影的守恒约束:

texel世界尺寸 = 阴影覆盖范围 ÷ 贴图尺寸

1000m 场景 ÷ 4096 ≈ 24cm/texel

PCF 模糊核宽度固定几个 texel → 边缘模糊带变宽到约 1 米(模糊);normalBias=0.2 连一个 texel 的深度差都盖不住(条纹,条纹方向沿 texel 网格,所以"同向平行")。到此,参数调优的余地已经耗尽——换架构的时候到了。

第六幕:CSM 登场,以及最后一个坑

CSM(级联阴影贴图)把相机视锥按深度切成 4 段,每段独占一张贴图:近段厘米级精度,远段自动接管。Babylon 的CascadedShadowGenerator继承ShadowGenerator,既有 caster 管理代码零改动。

但 CSM 上线后质量依然不佳——最后一个坑同样隐蔽:cascade 的分段范围默认按camera.maxZ(我们未设置,默认 10000)规划。代入 lambda=0.5(均匀+对数折中):

对数分割第1级边界:10m 均匀分割第1级边界:2500m

lambda=0.5 混合: ≈ 1255m

第一级 cascade 覆盖到 1255 米!精度预算几乎全分给了永远看不到的距离。解法是开启 GPU 深度缩减,按画面实际内容深度收紧分段(官方文档原话:"can greatly enhance the shadow quality")。

终章:最终方案(逐行注释)

沉淀到共享函数SunShadowGenerator.ts的最终形态:

import { CascadedShadowGenerator, DirectionalLight, ShadowGenerator } from "@babylonjs/core"; const SUN_SHADOW_MAP_SIZE = 2048; // 每级 cascade 的贴图边长;4级×2048 显存与单张4096相当 const SUN_SHADOW_BIAS = 0.0005; // 归一化深度偏移,acne 基础抑制;z跨度收紧后等效世界偏移仅厘米级 const SUN_SHADOW_NORMAL_BIAS = 0.1; // 法线偏移(世界单位),掠射角 acne 抑制;近级 texel 厘米级时此值充足 export function createSunShadowGenerator(sunLight: DirectionalLight): ShadowGenerator { // 4级CSM:按相机视锥深度自动分段,每级独占2048贴图。 // 第三参数 usefulFloatFirst=true:强制全浮点深度贴图,自遮挡场景(self-shadowing)的精度保障。 const shadowGenerator = new CascadedShadowGenerator(SUN_SHADOW_MAP_SIZE, sunLight, true); // 相机旋转时把 cascade 对齐到 texel 网格,防止阴影边缘"游动"(shimmer);代价是微量精度。 shadowGenerator.stabilizeCascades = true; // 核心自适应:GPU每帧对深度图做min/max缩减,按画面实际内容深度收紧cascade分段。 // 不开则分段按camera.maxZ(默认10000)规划,第1级覆盖上千米,近处精度被稀释。 shadowGenerator.autoCalcDepthBounds = true; // 深度缩减每2帧执行一次(而非每帧),降低GPU开销;视角快速移动时仅晚一帧自适应。 shadowGenerator.autoCalcDepthBoundsRefreshRate = 2; // 分段曲线偏向对数(0=均匀,1=对数):近处 cascade 分到更多精度预算。 // 官方建议 autoCalcDepthBounds 开启后提高 lambda,甚至可到1。 shadowGenerator.lambda = 0.9; // 归一化深度偏移:z跨度已被CSM收紧,此处世界偏移仅约 0.0005×数十米 ≈ 1~2cm。 shadowGenerator.bias = SUN_SHADOW_BIAS; // 沿接收面法线抬起采样点(0.1米):处理掠射角自遮挡;方向不在光路上,不会引起悬浮。 shadowGenerator.normalBias = SUN_SHADOW_NORMAL_BIAS; // PCF百分比渐进滤波:多采样柔化边缘,消除硬边锯齿。 shadowGenerator.usePercentageCloserFiltering = true; return shadowGenerator; }

配套清理同样重要:CSM 按相机视锥自行定位各级光源视锥,之前维护的"每 500ms 按 caster 包围盒更新太阳位置"的逻辑全部删除——光源的 position、shadowOrthoScale、autoUpdateExtends、autoCalcShadowZBounds 在 CSM 下均被忽略。

六次逼近的经验清单

  1. 现象会骗人,先找因果层——"没有阴影"可以是角度约定错误;"偏移"可以是 Z 范围;"模糊"可以是精度分配。别在渲染参数层怀疑之前,先验证数据流层。
  2. 共享数据必须配共享解释函数——两端各自实现的转换公式,迟早发散。
  3. 阴影参数先做量纲分析——bias的世界效果 = 数值 × Z 跨度;normalBias的合理量级 = texel 尺寸 ÷ tan(夹角)。
  4. 单级贴图有物理上限——texel = 范围 ÷ mapSize,场景尺度上到千米级就该上 CSM。
  5. 自适应优于调参——autoCalcShadowZBoundsautoCalcDepthBounds这类机制让方案对场景尺度、视角、用户操作全部免疫;面向不懂阴影原理的用户时,这是唯一出路。
  6. 每个修复可能暴露下一个问题——治好悬浮会放出粉刺,这不是倒退,而是精度层级在逐层逼近真相。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询