GPU Gems 1 · Chapter 3. Skin in the "Dawn" Demo
以 HDR 环境光、遮蔽、法线细节和轮廓透光近似 Dawn 的皮肤响应,并把计算拆到顶点与片段着色器。
学习目标
- 能解释 Dawn 皮肤为何不能只用一张颜色贴图和一个点光源,并说出环境光、遮蔽与轮廓项各自解决的问题
- 能从顶点阶段传递的切线到世界矩阵与视线向量,说明片段阶段如何还原法线并采样环境立方体贴图
- 能比较真实次表面散射与 Dawn 的实时近似,识别粗糙度、遮蔽项和轮廓光对“塑料感”的不同影响
- 能修改皮肤着色实验的参数,先预测再验证高光、边缘光和环境遮蔽的变化
为什么 Dawn 的皮肤难以看起来像皮肤
NVIDIA 制作 Dawn 是为了展示 GeForce FX 可编程硬件如何把实时角色推向电影感。人类对脸部表情、皮肤颜色和边缘明暗非常敏感;即使观众不认识“次表面散射”这个词,也能立刻察觉脸像塑料、边缘像剪纸,或阴影硬得不自然。
先预测:一张颜色贴图已经包含脸颊的红色,为什么还需要把光线送进皮肤的近似?关键在于颜色贴图描述的是“本来是什么颜色”,而光照要解释“从哪个方向来、经过哪些组织、在边缘怎样变软”。本章用一套为 Dawn 的模型、镜头距离和硬件预算定制的着色器,把这些感知线索组合起来。
1. 皮肤响应:从分层组织到实时线索
皮肤由半透明的表皮、真皮和皮下组织组成。光进入表面后可能在组织内部散射,再从附近位置离开;这种现象称为 ↡光进入表面后在介质内部散射,并从另一个邻近位置离开的现象。把光只看成“同一点入射、同一点反射”的模型,会漏掉手指对着太阳时边缘发红、脸颊透出柔光等线索。
完整的体积输运很昂贵,因此 Dawn 使用了几种可控近似:一张颜色贴图提供基础肤色;环境立方体贴图提供大范围光照;切线空间法线带来毛孔和细皱的微变化;视线接近轮廓时,从角色背后的环境采样一份柔和亮度,再混入基础颜色。
这里的核心不是声称“模拟了所有散射”,而是把观众最敏感的信号拆开。可以把皮肤着色理解成:环境负责整体色调,遮蔽负责哪里变暗,法线负责微表面朝向,轮廓项负责边缘的透光错觉。
2. HDR 环境光如何进入实时着色
传统实时架构常用点光、方向光和聚光灯;这会让白纸和白色光源都被压到相近的亮度,角色像被贴进场景。Dawn 的制作流程使用多张不同曝光的校准照片拼出全景,再生成 ↡能同时表达远亮于白色和远暗于黑色的亮度范围 环境数据,让每个方向不只存颜色,也保留亮度差异。
从 HDR 环境可以预计算三类方向查表。↡把某个方向的环境光预先卷积为低频漫反射结果的立方体纹理按表面法线索引,近似余弦加权的半球光;↡按反射方向索引、并按材质粗糙度预先模糊的环境纹理按反射向量索引;另一张高光环境图按视线方向取角色背后的亮区域。
立方体贴图有一个必须主动接受的边界:它对拍摄中心点最准确,角色走动时背景会像无限远。对 Dawn 这种小角色和远景,这个近似是合适的;若场景要展示近处墙面或大幅移动,就需要局部环境、阴影或更完整的空间光照。
3. 遮蔽项:让环境光知道哪里被挡住
环境图描述的是相机拍到的入射光,却不知道 Dawn 的头发、肩膀和身体会遮挡自己的半球。为此,原书用软件工具从每个顶点的可见半球随机发射射线,统计有多少方向撞上其他几何,再把比例压缩成 ↡表示一个顶点上方半球有多少入射光被几何挡住的预计算量。
这个项不必每帧重新计算。Dawn 的遮蔽项保持在角色顶点上;脸部如果有 morph target,则在不同形状的遮蔽项之间做混合。这样得到的是柔和的环境暗部,而不是需要额外实时 pass 的硬阴影。注意它是“预计算的可见性近似”,不是动态阴影的替代品;角色与场景关系大幅变化时,旧项会失效。
4. 法线、轮廓与血液透射项
皮肤表面的细小毛孔和汗毛太小,不能按几何建模。Dawn 使用切线空间法线贴图,并把一些高光强度信息塞进贴图的另一个通道。↡在纹理中记录相对于切线基的局部表面方向,再转换到世界空间的法线数据让同一套细节可以沿模型表面移动,同时不会把网格顶点数量推得过高。
轮廓附近是另一个敏感区域。当视线方向与表面法线接近垂直时,N · V 变小;这时可以从角色背后的环境取光,和基础肤色混合,制造边缘透光的错觉。原书把多个相关量打包为 ↡由遮蔽、视线夹角和法线朝向等量组成的轮廓/透光辅助向量,供片段阶段按模型和视觉需要调制。
数学上,若 V 是视线方向,常用的边缘因子可以写成:
它不是完整的组织散射方程,却能把“越接近轮廓,背光越显眼”的视觉规律变成可调的信号。先把 rim 视作独立因素,再决定要不要叠加 bump 和高光,调参才不会互相遮蔽。
Dawn Skin Lab
先猜:高光变亮会让皮肤更真实吗?
观察:粗糙度只控制高光扩散;遮蔽项降低环境光,轮廓项负责边缘的柔和感。
5. 顶点阶段:把昂贵但可插值的量提前
本章的皮肤方案是一遍着色:不需要再做额外的渲染 pass 或 alpha 混合。顶点着色器首先接收位置、法线、切线、UV、morph target 偏移以及骨骼索引/权重。对 Dawn 的高分辨率网格,顶点阶段先做形状混合,再做索引蒙皮;这些角色变形的详细控制属于下一章,本章只关心它们如何提供最终姿态。
接着顶点阶段把法线和切线变换到世界空间,并构成一个 ↡把切线空间向量旋转到世界空间的 3×3 基矩阵。它还计算世界空间视线方向、N · V 的轮廓参数和遮蔽/透射辅助量。片段阶段不必再次重做这些昂贵的矩阵变换,只需插值这些输出。
一个缩减后的顶点阶段骨架如下。它强调数据契约,不是完整可编译源码:
struct Varyings {
float4 clipPosition;
float2 skinUv;
float3 worldEyeDirection;
float3 tangentToWorldX;
float3 tangentToWorldY;
float3 tangentToWorldZ;
float4 skinSilhouette;
};
Varyings dawnVertex(Input IN, Uniforms U) {
float3 objectPosition = applyMorphTargets(IN, U.morphWeights);
float3 worldPosition = skinPosition(objectPosition, IN.boneIndex, IN.boneWeight, U.bones);
float3 worldNormal = skinNormal(applyNormalMorphs(IN, U.morphWeights), IN, U.bones);
float3 worldTangent = orthonormalize(IN.tangent, worldNormal, U.bones);
float3 worldBinormal = normalize(cross(worldNormal, worldTangent));
Varyings OUT;
OUT.worldEyeDirection = normalize(U.cameraPosition - worldPosition);
OUT.tangentToWorldX = float3(worldTangent.x, worldBinormal.x, worldNormal.x);
OUT.tangentToWorldY = float3(worldTangent.y, worldBinormal.y, worldNormal.y);
OUT.tangentToWorldZ = float3(worldTangent.z, worldBinormal.z, worldNormal.z);
OUT.skinSilhouette = makeSilhouetteTerms(worldNormal, OUT.worldEyeDirection, IN.occlusion);
OUT.clipPosition = mul(U.projection, mul(U.view, float4(worldPosition, 1)));
OUT.skinUv = IN.skinUv;
return OUT;
}这里的 ↡在图形流水线中由顶点处理阶段执行的程序,负责变换位置并准备插值数据把数据整理成稳定接口;不要为了追求“全都逐像素”而把本可插值的矩阵和轮廓量全部推迟。
6. 片段阶段:把法线细节与环境光合成
片段阶段先从纹理读取切线空间法线,再用顶点阶段传来的矩阵转换到世界空间。↡在光栅化后为每个可见片段计算最终颜色的程序可以按世界法线读取漫反射环境图,按反射向量读取镜面环境图,再用遮蔽项和 rim 调制;最后把高光、背光和基础颜色合成到一遍输出中。
float4 dawnFragment(Varyings IN, Textures T) {
float4 normalSpec = sample2D(T.normalSideSpec, IN.skinUv);
float3 tangentNormal = normalize(normalSpec.xyz * 2.0 - 1.0);
float3 worldNormal = normalize(float3(
dot(tangentNormal, IN.tangentToWorldX),
dot(tangentNormal, IN.tangentToWorldY),
dot(tangentNormal, IN.tangentToWorldZ)));
float nDotV = saturate(dot(IN.worldEyeDirection, worldNormal));
float3 skin = sample2D(T.skinColor, IN.skinUv).rgb;
float3 diffuse = skin * sampleCube(T.diffuseCube, worldNormal);
float3 specular = normalSpec.w * sampleCube(T.specularCube, reflect(-IN.worldEyeDirection, worldNormal));
float3 backLight = sampleCube(T.hilightCube, IN.worldEyeDirection).rgb;
float rim = pow(1.0 - nDotV, 2.0) * IN.skinSilhouette.y;
float3 result = diffuse * IN.skinSilhouette.x + specular + backLight * rim;
return float4(result, rim * normalSpec.w);
}代码中的 normalSpec.w 代表把法线与高光强度共用一张纹理的工程取舍;它减少输入数量,却要求美术和着色器约定好每个通道的职责。↡把向量按环境方向索引立方体纹理以取得颜色和亮度的采样方式则把远处光照压缩成几次方向查表。
7. 为特定角色优化,而不是宣称通用皮肤
原书特别强调 Dawn 的着色器高度针对模型与镜头范围。Dawn 网格足够细,N · V 等量在线性插值后仍能接受;如果换成低模角色、极近的脸部镜头或完全不同的骨骼,就需要重新评估误差。Dawn 的顶点着色器还由规则文件和搜索过程按角色设置生成,目标是只保留当前 fragment shader 真正请求的输入。
这是一条很实用的工程结论:实时效果可以为固定内容“量身定做”。如果产品要支持许多角色与任意视距,再把其中的环境、遮蔽、法线和轮廓思路抽成参数化材质,并为每个质量档位设定预算;不要把一套展示 Demo 的经验直接包装成万能模型。
本章回顾:让皮肤线索各司其职
- HDR 环境提供大范围亮度与颜色,不再只依赖几个点光源。
- 遮蔽项压低被角色自身挡住的环境光,形成柔和暗部。
- 切线空间法线提供微细表面变化,轮廓项近似边缘透光。
- 顶点阶段准备矩阵、视线和辅助项,片段阶段完成细节采样与合成。
- Dawn 的方案是针对模型和镜头的实时近似,适用边界必须显式保留。
练习
问题 1 改 Demo 代码:将实验中的遮蔽项固定为 1.0,再将它固定为 0.2。分别预测脸部环境光与轮廓光会怎样变化,并说明这与实时阴影有什么不同。
问题 2 推导题:若世界空间法线 N=(0,0,1),视线方向分别为 V₁=(0,0,1) 与 V₂=(0.98,0,0.2),用 rim=(1-saturate(N·V))² 比较两个点的轮廓因子。
问题 3 场景选型题:一个角色需要在固定的近景脸部镜头中表现柔和边缘光;另一个系统需要支持许多低模角色并允许相机贴近任意部位。分别说明 Dawn 方案哪些部分可以直接采用,哪些部分必须重新设计。
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 次表面散射
- 光进入皮肤后在组织内部散开,再从附近位置透出的现象;它让边缘不至于像硬塑料。
- 高动态范围
- 同时保存很亮和很暗的环境亮度,避免所有白色区域都挤成同一个亮度。
- 漫反射立方体贴图
- 按表面法线查找预先平均好的环境光,提供柔和的整体亮度。
- 镜面立方体贴图
- 按反射方向查找环境高光,并用模糊程度近似材质粗糙度。
- 遮蔽项
- 记录周围几何挡住多少环境光的预计算比例,用来压低不该太亮的区域。
- 切线空间法线
- 相对于表面切线基记录的微小朝向,能在不增加几何的情况下做细节光照。
- 血液透射项
- 把遮蔽、视线夹角和法线朝向等辅助量打包给轮廓与背光近似使用的向量。
- 切线到世界矩阵
- 把法线贴图里的局部方向旋转到世界空间的三乘三基。
- 顶点着色器
- 逐顶点运行,负责位置变换和准备可供后续片段插值的向量。
- 片段着色器
- 逐可见片段运行,读取纹理和环境光并输出最终颜色。
- 环境立方体采样
- 用一个方向去查立方体纹理,取得该方向的远处环境颜色和亮度。