从“没有阴影“到“画面完美“:Babylon.js 阴影方案的六次逼近

发布时间:2026/7/30 9:36:41
从“没有阴影“到“画面完美“:Babylon.js 阴影方案的六次逼近 引子一个会骗人的 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是各自手工配置的编辑器运行时贴图20482048过滤无硬边锯齿PCF柔化bias00.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/texelPCF 模糊核宽度固定几个 texel → 边缘模糊带变宽到约 1 米模糊normalBias0.2 连一个 texel 的深度差都盖不住条纹条纹方向沿 texel 网格所以同向平行。到此参数调优的余地已经耗尽——换架构的时候到了。第六幕CSM 登场以及最后一个坑CSM级联阴影贴图把相机视锥按深度切成 4 段每段独占一张贴图近段厘米级精度远段自动接管。Babylon 的CascadedShadowGenerator继承ShadowGenerator既有 caster 管理代码零改动。但 CSM 上线后质量依然不佳——最后一个坑同样隐蔽cascade 的分段范围默认按camera.maxZ我们未设置默认 10000规划。代入 lambda0.5均匀对数折中对数分割第1级边界10m 均匀分割第1级边界2500mlambda0.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贴图。 // 第三参数 usefulFloatFirsttrue强制全浮点深度贴图自遮挡场景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 下均被忽略。六次逼近的经验清单现象会骗人先找因果层——没有阴影可以是角度约定错误偏移可以是 Z 范围模糊可以是精度分配。别在渲染参数层怀疑之前先验证数据流层。共享数据必须配共享解释函数——两端各自实现的转换公式迟早发散。阴影参数先做量纲分析——bias的世界效果 数值 × Z 跨度normalBias的合理量级 texel 尺寸 ÷ tan(夹角)。单级贴图有物理上限——texel 范围 ÷ mapSize场景尺度上到千米级就该上 CSM。自适应优于调参——autoCalcShadowZBounds、autoCalcDepthBounds这类机制让方案对场景尺度、视角、用户操作全部免疫面向不懂阴影原理的用户时这是唯一出路。每个修复可能暴露下一个问题——治好悬浮会放出粉刺这不是倒退而是精度层级在逐层逼近真相。

相关新闻

最新新闻

日新闻

周新闻

月新闻