少女前线2 追放人物渲染复刻
本文最后更新于16 天前,其中的信息可能已经过时,如有错误请留言

Reference:

(53 封私信 / 26 条消息) 少女前线2:追放 vepley角色渲染分析还原 – 知乎

(53 封私信 / 18 条消息) 少女前线2渲染还原分析—以Cheeta为例 – 知乎

(53 封私信 / 24 条消息) 高精度 高质量 自适应 角色包围盒阴影 Unity ShadowMap – 知乎

(53 封私信 / 26 条消息) 二次元角色卡通渲染—面部篇 – 知乎

(53 封私信 / 26 条消息) 二次元角色卡通渲染—眼睛篇 – 知乎

贴图设置

参数讲解

SRGB off

  • 关闭颜色空间校正,把贴图当“数据”读,而不是当普通颜色读
  • 适合Mask、Roughness、Metallic、AO、LUT、Ramp、噪声图、各种查表控制图
  • 不适合Albedo/Base Color 这种肉眼看的颜色贴图,通常应该开 sRGB

Mip Map = Off

  • 不生成远距离用的小尺寸版本
  • 适合UI、Sprite、像素风、LUT、数据贴图、需要精确采样的贴图
  • 不适合贴在 3D 模型上的大面积纹理,远处可能闪烁、锯齿、摩尔纹
  • 为什么还是需要在胡迪尼中制作贴图:Unity 自动生成的 Mip Map只是把原图一层层缩小,其目的是解决远处物体贴图不要闪烁,不要摩尔纹,但 Houdini 里制作 mipmap可以自定义每一级内容,哪些地方保留,哪些地方扩张,哪些地方不混合,不是单纯缩小图片

Compression 控制贴图压缩

  • None:不压缩,质量最好,显存/包体更大。适合 LUT、Ramp、Mask、UI、像素图、精确数据图
  • Normal Quality 默认/均衡压缩
  • High Quality 更高质量压缩,可能占用更多内存

Wrap Mode 决定 UV 超出 0~1 后怎么采样

  • (53 封私信 / 16 条消息) 纹理优化:让你的纹理也“瘦”下来 – 知乎
  • Repeat:默认是Repeat。该方法以平铺的方式填满“画框”的剩余部分
    • 适合“应该重复”的纹理:当 UV 超出 0~1 时,纹理会按周期继续平铺。最常见于平铺地面、噪声图、滚动水波、流动贴图、条纹和程序化图案
  • Clamp模式,它会以“画作”边缘那一圈去采样填充满“画框”中剩余的部分。从UV坐标的角度而言,该模式会将大于1.0的数值设置为1.0,将小于0.0的数值设置为0.0
    • 适合“不应该重复”的纹理:当 UV 超出 0~1 时,采样会停留在边缘像素,因此越界区域会像被“拉边”一样延展,不再出现重复纹样。

Filter Mode:决定放大缩小时像素怎么插值

Wrap Mode 解决的是“坐标越界”,而 Filter Mode 解决的是“一个屏幕像素到底该读哪几个纹理像素”。 它直接影响你的纹理是清脆、柔和,还是远处更稳定

  • Point:最锐利,但会块状
    Point 是最近邻采样,不做平滑插值。优点是边缘硬、像素干净;缺点是放大后很容易看到“方块感”
    • 适合:像素风、低分辨率风格、需要硬边的图案。
    • 不适合:写实材质、需要柔和过渡的大面积纹理。
  • Bilinear 最常见的默认平滑
    Bilinear 会对当前纹理层内相邻 texel 做线性插值。它比 Point 平滑得多,也因此会带来一点模糊感。
    • 适合:大多数常规 3D 贴图与一般 UI 图片。
    • 特点:成本和质量通常比较平衡
  • Trilinear 远处更稳定
    Trilinear 在 Bilinear 的基础上,还会在相邻 Mipmap 层之间继续混合,因此远距离、斜视角时的纹理过渡更自然
    • 适合:有 Mipmap 的 3D 贴图、远景地面、倾斜表面。
    • 注意:没有 Mipmap 时,它与 Bilinear 的效果接近。

常见问题与对应解决方案

项目里怎么选最稳妥?

Wrap Mode 选择建议

地面 / 噪声 / 流动纹理:优先 Repeat。
UI / 渐变 / 遮罩 / LUT:优先 Clamp。
一看到边缘重复:先别怀疑 Shader,先看 Wrap。
一看到接缝:先确认贴图本身是否无缝。
Filter Mode 选择建议

像素风:Point。
绝大多数常规贴图:Bilinear。
远景 3D / 开启 Mipmap 的表面:Trilinear。
觉得纹理太糊:先排查 Filter,再看分辨率与 Mipmap。
非常实用的经验法则:
Repeat / Clamp 主要回答“该不该重复”;Point / Bilinear / Trilinear 主要回答“该不该平滑”。
如果你把这两句话记住,Unity 里八成的纹理采样问题都能第一时间定位到方向

具体设置

  • RMO贴图:sRGB off,Generate Mipmaps on,Use MipMap limits off,Texture type = Default, Compression = None / Normal quality,Wrap Mode = Reapet,Filter Mode = Bilinear
    • RMO存储的是Roughness Metallic AO,如果压缩太狠,可能会出现色块、噪点
  • SDF贴图
  • Matcap
    • 如果是 Repeat,可能会把右边缘和左边缘、上边缘和下边缘混进来。MatCap 通常不希望重复平铺

Material capture (“MatCap”)

用贴图模拟材质和光照效果
使用一张预先渲染好的“材质球”2D 贴图
渲染的时候把模型表面的法线转换到视角空间,再用这个法线方向去 MatCap 贴图中寻找对应位置,并取该位置的颜色作为模型当前像素的显示颜色

一句话总结的话:直接用法线方向查颜色,伪造材质/光照

  • MatCap 贴图的 RGB:记录颜色结果。
    MatCap 贴图的位置 UV:代表法线方向
  • 例如下图图二(黑白)中,球形就是Matcap贴图,箭头代表了右下朝向的法线,车与人的模型法线朝向此相同方向时,就会渲染为球形贴图上相应点的颜色——白色
  • Matcap的优点:
    • 运算效率极高,计算成本极低,在很多场合却能够替代PBR。用Unlit级别的计算成本,获得3A级的渲染效果,这正是Matcap吸引人的地方。
    • 充满了很多新的可能性,只需简单更换Matcap贴图,就能实现各种神奇的渲染效果
  • 缺点
    • 基础Matcap只适用于单一材质、单一固有色且无法线贴图细节的模型
      • 因为渲染时根据模型在视角空间的法线在Matcap贴图中采样,那么模型上只要法线方向一样,就会取到一样的颜色
    • MatCap 通常无法响应场景中光源的位置、颜色和强度变化
      • 相机变化时,它也不会像真实反射或 IBL 那样根据世界空间中的环境重新计算反射,而是通常基于视角空间法线去采样贴图,所以高光和明暗关系往往会“跟着镜头走”。
      • 这也是 MatCap 和 IBL 的一大区别:MatCap 存的是已经完成的材质/光照结果,而 IBL 存的是环境光信息,渲染时还会结合模型法线、视线方向、粗糙度、菲涅尔等参数重新计算光照和反射
    • 在平面、圆柱体及相近形体的渲染上效果并不好(见下图)
  • 法线贴图是输入数据。MatCap 是用法线去查出来的最终外观

IBL Image-Based Lighting(基于图像的照明)

What:
用贴图来表示周围光照环境的渲染技术。它不是一个具体灯光,而是用一张环境贴图来提供周围环境的光照信息。常见形式有: HDRI、Cubemap 或 Reflection Probe

Why:通过IBL就能低成本的捕捉环境的全局光照和整体感觉。让角色感觉“处在一个环境里”,而不是只被一盏灯照着,让物体在环境中有更好的归属

How核心思想:
把环境中来自各个方向的光照信息预先存储到环境贴图中,并通过预卷积 / 预过滤的方式提前计算部分光照积分。渲染时 Shader 只需要根据当前像素的法线、视线方向、粗糙度等参数去采样这些贴图,从而用较低成本近似获得环境漫反射和环境镜面反射。

具体来说,IBL 相比单个光源,把“环境中很多方向来的光”预先整理进贴图里,Shader 不需要每帧对整个半球做积分,只需要按方向采样。

  • 普通光源:
    只算一个方向的光,例如 Directional Light 的 L。
  • IBL:
    把周围环境所有方向的光照信息存进环境贴图,
    运行时根据 normal / viewDir / roughness 等参数去查表。
  • Diffuse IBL:用于环境漫反射

预先把环境光对每个法线方向的半球积分结果存起来。用法线方向采样
常见形式:Irradiance Map / SH。

half3 diffuseEnv = SAMPLE_TEXTURECUBE(
    _IrradianceCube,
    sampler_IrradianceCube,
    normalWS
).rgb;
  • Specular IBL:用于环境镜面反射

预先根据 roughness 对环境贴图做不同程度的模糊。运行时根据 reflectDir 反射方向(视线方向 + 法线方向 → 算出反射方向)和 roughness 采样。
常见形式:Prefiltered Cubemap
漫反射不关心视线方向,主要关心表面朝向哪里

half3 reflectDir = reflect(-viewDirWS, normalWS);
half3 envCol = SAMPLE_TEXTURECUBE_LOD(
    _EnvCube,
    sampler_EnvCube,
    reflectDir,
    mip
).rgb;
  • BRDF LUT:

预计算一部分和 NoV、roughness、Fresnel 有关的积分项。
用于补全 PBR IBL 的镜面反射

  • Cubemap 怎么知道“哪个方向”对应哪张图?

Cubemap 不是普通 2D 图,它是一个立方体环境贴图,有 6 个面:+X / -X / +Y / -Y / +Z / -Z
你给它一个 3D 方向向量,比如:reflectDir = float3(0.8, 0.2, 0.5);
GPU 会自动判断这个方向主要朝哪个面,然后换算成那个面的 UV 坐标

  • roughness 怎么参与采样?

roughness 不决定采样方向,它决定采样 哪个 mip 层级
roughness 低→ 采样低 mip→ 环境反射清晰→ 像镜子 / 金属 / 胶衣
roughness 高→ 采样高 mip→ 环境反射模糊→ 像粗糙皮肤 / 布料

  • BRDF LUT 又是怎么采样的?

完整 PBR IBL 里还会有一张 2D 的 BRDF LUT,它不是 Cubemap,而是普通二维查找表,其
x = NoV,y = roughness,采样代码如下:

half NoV = saturate(dot(normalWS, viewDirWS));
half2 brdfUV = half2(NoV, roughness);

//当前视角有多斜?当前材质有多粗糙?根据这两个参数查预计算好的 BRDF 补偿值。
half2 brdf = SAMPLE_TEXTURE2D(
    _BRDFLUT,
    sampler_BRDFLUT,
    brdfUV
).rg;

立方体贴图(CubeMap)

 CubeMap是一个由六个独立的正方形纹理组成的集合,它将多个纹理组合起来映射到一个单一纹理,通常被用来作为具有反射属性物体的反射源

原理讲解(没有补齐)

Reference:LearnOpenGL – 漫射光照度

基于图像的照明算法捕捉某些(全局)环境的光照,考虑到环境光照,PBR看起来更为物理准确。
为了开始将IBL引入PBR系统,我们再次快速看看反射率方程:

SDF

而至于为什么要使用面部SDF贴图呢?因为在日本动漫里, 角色面部的光影往往是简化过的, 而使用真实的光影就不太适合

这张 SDF 贴图本质上是一张 阴影/高光蔓延阈值图。它放弃了引擎默认的面部实时法线光照计算,改为通过读取贴图信息来决定阴影的形状。当光源旋转时,Shader 会根据光源角度和这张图里的灰度值进行对比,从而计算出一条非常干净、锐利且完全由美术自定义的阴影边缘。

R通道存储的是面部Diffuse Shadow的SDF, G通道和B通道存储的是面部鼻尖和嘴唇高光的SDF

这张 SDF 贴图本质上是一张 阴影/高光蔓延阈值图。它放弃了引擎默认的面部实时法线光照计算,改为通过读取贴图信息来决定阴影的形状。当光源旋转时,Shader 会根据光源角度和这张

具体动效可参考:(53 封私信 / 18 条消息) 少女前线2渲染还原分析—以Cheeta为例 – 知乎

反射方程

基于物理的渲染(Physically Based Rendering,PBR)是指使用基于物理原理和微平面理论(microfacet theory)建模的着色/光照模型,以及使用从现实中测量的表面参数来准确表示真实世界材质的渲染理念。

菲涅尔系数 F

法线分布函数 D

几何函数 G

  • G 是几何函数(Geometry Function),用于描述几何遮蔽(Geometry Obstruction)和几何阴影(Geometry Shadowing)(值的范围是 0-1)

当高光来自真实灯光对象时,为了让 GGX 高光更稳定/更好看,会对 roughness 做一个特殊修正。

类型中文理解例子
解析光源用数学公式直接算的灯点光、方向光、聚光灯
IBL从环境贴图里采样出来的光HDRI、天空盒、Reflection Probe

金属工作流 Metallic Workflow

之前提到的 Schlick 版本的菲涅尔公式,只能用于描述电介质(如玻璃,水)。对于导体(如金属),要使用另一个菲涅尔公式。这就很麻烦。金属工作流则使用参数金属度(metalness)来确定金属性。为了能让菲涅尔简化公式能同时用于金属和非金属

用金属性来对 F0进行插值,非金属的 F0默认近似为 0.04,金属的 F0则为表面颜色。

vec3 F0 = vec3(0.04);
F0      = mix(F0, surfaceColor.rgb, metalness);

水体必看必看: https://zhuanlan.zhihu.com/p/95917609

(53 封私信) 总结一些TA(技术美术)学习的网站 – 知乎

(53 封私信 / 4 条消息) 少女前线2:追放 vepley角色渲染分析还原 – 知乎

(53 封私信 / 2 条消息) 秋大叔 – 知乎

待实现的另一种SSS实现方法:(53 封私信 / 8 条消息) 快速sss次表面散射实践指北 – 知乎
描边:(53 封私信 / 8 条消息) 【01】从零开始的卡通渲染-描边篇 – 知乎

https://zhuanlan.zhihu.com/p/2013370672647268314

https://zhuanlan.zhihu.com/p/2028819446546932894

https://zhuanlan.zhihu.com/p/2008161152492917429

资源

一、贴图分析

  • d = Diffuse / BaseColor / Albedo 颜色贴图
  • rmo = Roughness + Metallic + Occlusion 打包贴图
  • normal = Normal 法线贴图
  • envCube = Environment Cube / Cubemap 环境反射贴图

(53 封私信 / 6 条消息) 【01】从零开始的卡通渲染-描边篇 – 知乎

描边是卡通渲染的一个非常重要的主题。目前比较流行的描边方法有两种,一个是通过两次绘制,一次绘制角色,一次绘制描边。还有一种是基于后处理的描边。基于后处理的描边相对不容易定制,比较适用于对复杂场景进行描边。这里讲述通过2次绘制来绘制描边的方法。在《GUILTY GEAR Xrd》中称其为Back Facing法

基本思路是通过两次绘制,第一次绘制角色,第二次绘制描边。绘制描边的时候,在顶点着色器将顶点沿着法线方向位移一段距离,使得模型轮廓放大,渲染作为描边。同时描边绘制时使用cull front。这样描边和角色重叠的部分会因为不能通过深度检测而cull掉,保证描边不会遮挡角色。两次绘制颠倒顺序也是可以的,不过后绘制描边,可以通过深度检测过滤掉很多描边绘制的像素,效率会更好。这里先实现最简单的方法,然后逐步进行优化。

一个收藏量很恐怖的开源项目,但似乎没有给完整版的ColinLeung-NiloCat/UnityURPToonLitShaderExample: A very simple toon lit shader example, for you to learn writing custom lit shader in Unity URP

1.头发

(53 封私信 / 8 条消息) Unity基于Kajiya-Kay的头发渲染 – 知乎

(53 封私信 / 8 条消息) 基于Kajiya-Kay模型的毛发渲染 – 知乎 让本来不会移动的天使环乘以半程向量“假”移动,同时指出:
因为其计算简单而广为传播和使用,同时基于该模型演变出很多头发高光的解决方案。由于Kajiya-Kay模型是肉眼观察出来的模型,同时也是能量不守恒的,因此《GDC2004头发渲染和着色》提出了另一种头发渲染方式,让头发渲染更加趋近于真实

  • 在少女前线的方案中高光形状主要由贴图控制,用个BlinnPhong算出高光再乘上mask,给个最小值来个基础色。
  • 而 Kajiya-Kay 是:Shader 根据发丝方向、光照、视角实时算出条状高光(将来可在其他模型上实现)

整体思路:用 Toon/Ramp 做头发明暗,用 HairSpecTex 定义高光形状,用 View Space 视角滑动和 BlinnPhong 让高光随相机旋转与光源变化,用 MatCap 增加二游式反光;同时通过额外的 FringeShadowCaster Pass 把 fringeuber 前发生成屏幕空间刘海投影 Mask,再交给 Face Shader 接收成脸部阴影。

同时因为这个头发的高光渲染本身已经不太“物理”,所以并没有使用GGX高光进行计算, 而是在保证能量守恒的情况下使用了归一化的BlinnPhong进行计算(减少计算量)

Shader 分成两个 Pass

  • Forward Pass:
    负责正常显示头发:BaseMap、卡通明暗、Ramp、高光、视角滑动、MatCap。
  • FringeShadowCaster Pass:
    不显示头发,只把刘海区域写入屏幕空间 Mask,给脸部 Shader 接收成刘海投影

基础颜色

头发基础颜色来自 _BaseMap,再乘 _BaseColor 做整体染色

half4 baseTex = SAMPLE_TEXTURE2D(
    _BaseMap,
    sampler_BaseMap,
    TRANSFORM_TEX(input.uv0, _BaseMap)
);

// 头发基础颜色,后面的明暗、高光、MatCap 都基于它
half3 albedo = baseTex.rgb * _BaseColor.rgb;

卡通明暗 Toon Lighting

half3 lightDirWS = normalize(mainLight.direction);
half ndotl = saturate(dot(normalWS, lightDirWS));

// 接收主光阴影
half shadowAtten = lerp(
    1.0,
    mainLight.shadowAttenuation,
    _ShadowReceiveStrength
);

// 卡通明暗分界
half toonLight = smoothstep(
    _ShadowThreshold - _ShadowSoftness,
    _ShadowThreshold + _ShadowSoftness,
    ndotl
);

toonLight *= shadowAtten;

// 不使用 Ramp 时的基础明暗
half3 simpleLighting = lerp(
    albedo * _ShadowColor.rgb,
    albedo * mainLight.color.rgb * _LightStrength,
    toonLight
);
  • Shadow Threshold:控制明暗分界位置
    Shadow Softness:控制明暗边缘软硬
    Shadow Color:暗部颜色
    Shadow Receive Strength:接收主光投影的强度

Ramp 明暗

开启 _UseRamp 后,头发明暗颜色不再只靠 _ShadowColor,而是采样 Ramp 贴图。

half3 SampleDiffuseRamp(half ndotl01)
{
half3 rampCol = SAMPLE_TEXTURE2D(
_RampTex,
sampler_RampTex,
float2(ndotl01, _DiffuseRampV)
).rgb;

return rampCol;
}

实际使用:

half ndotl01 = saturate(ndotl * 0.5 + 0.5);

half3 rampLighting =
albedo *
SampleDiffuseRamp(ndotl01 * shadowAtten) *
mainLight.color.rgb *
_LightStrength;

// UseRamp = 0 用普通卡通明暗
// UseRamp = 1 用 Ramp 控制明暗颜色
half3 lighting = lerp(simpleLighting, rampLighting, _UseRamp);

环境补光 Ambient

// 手写环境亮度,避免头发暗部完全死黑
lighting += albedo * _AmbientStrength;

手绘高光 Mask

头发高光形状主要来自 _HairSpecTex,不是完全靠实时光照生成。

half GetSpecRaw(float2 uv)
{
half3 specTex = SAMPLE_TEXTURE2D(
_HairSpecTex,
sampler_HairSpecTex,
uv
).rgb;

// 取 RGB 中最亮的通道作为高光 Mask
return max(specTex.r, max(specTex.g, specTex.b));
}

选择 UV:

float2 specUV0 = TRANSFORM_TEX(input.uv0, _HairSpecTex);
float2 specUV1 = TRANSFORM_TEX(input.uv1, _HairSpecTex);

// 可以选择用 UV0 或 UV1
float2 baseSpecUV = lerp(specUV0, specUV1, step(0.5, _SpecUVSet));
  • Spec UV Set:选择高光用 UV0 还是 UV1
  • Spec Offset / Tiling:调整高光位置和缩放
  • Spec Threshold:高光出现门槛
    Spec Softness:高光边缘软硬
    Spec Power:高光集中程度

BlinnPhong 控制高光动态强度

HairSpecTex 决定“哪里有高光”,BlinnPhong 决定“当前这个高光有多亮”。

half3 halfDirWS = normalize(lightDirWS + viewDirWS);
half ndoth = saturate(dot(normalWS, halfDirWS));
half blinn = pow(ndoth, _BlinnPhongPower);

// 控制高光是否受光照面影响
half lightGate = lerp(
1.0,
smoothstep(0.0, 0.5, ndotl),
_SpecLightInfluence
);

// UseBlinnPhong 控制高光有多少受 BlinnPhong 影响
half specDrive = lerp(1.0, blinn, _UseBlinnPhong);

// 保底高光,避免完全消失
specDrive = max(specDrive, _SpecMinimum);

specDrive *= lightGate;

最终颜色合成

float2 matcapUV;

half3 matcapFinal = GetMatCapColor(
normalWS,
viewDirWS,
ndotl,
specMask,
matcapUV
);

half3 finalColor = lighting + specFinal;
finalColor = BlendMatCap(finalColor, matcapFinal);

finalColor *= _CharacterTint.rgb * _CharacterBrightness;

return half4(finalColor, baseTex.a);

最终结构:

Toon / Ramp 明暗
+ Hair Spec 高光
+ MatCap 反光
→ Character Tint / Brightness
→ 输出头发颜色

刘海投影 Pass

这个 Pass 不负责显示头发,只负责给脸部 Shader 生成刘海投影 Mask

Pass
{
Name "FringeShadowCaster"
Tags { "LightMode" = "GF2FringeShadowCaster" }

Cull Off
ZWrite Off

// 使用相机深度遮挡,避免背面头发穿透脸部写入 Mask
ZTest LEqual

// 多个前发片叠加
Blend One One

// 只写 R 通道
ColorMask R

HLSLPROGRAM

#pragma vertex FringeShadowCasterVert
#pragma fragment FringeShadowCasterFrag

#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"

顶点阶段:把刘海轮廓按主光方向在 View Space 中偏移。

FringeShadowCasterVaryings FringeShadowCasterVert(FringeShadowCasterAttributes input)
{
FringeShadowCasterVaryings output;

float3 positionWS = TransformObjectToWorld(input.positionOS.xyz);
float3 positionVS = TransformWorldToView(positionWS);

// 主光方向转 View Space
Light mainLight = GetMainLight();
float3 lightDirWS = normalize(mainLight.direction);
float3 lightDirVS = normalize(mul((float3x3)UNITY_MATRIX_V, lightDirWS));

// 顶视角减少 Y 偏移,避免从上看阴影过度下移
float3 viewDirWS = normalize(GetCameraPositionWS() - positionWS);
half topViewFactor = saturate(abs(viewDirWS.y));

half camDirFactor = lerp(
1.0,
1.0 - smoothstep(0.1, 0.9, topViewFactor),
_FringeCameraTopFade
);

// 核心:按光照方向偏移刘海轮廓
positionVS.x -= lightDirVS.x * _FringeOffsetScaleX;
positionVS.y -= _FringeOffsetScaleY * camDirFactor;
positionVS.z += _FringeDepthBias;

output.positionCS = mul(UNITY_MATRIX_P, float4(positionVS, 1.0));
return output;
}

片元阶段:只有 fringeuber 材质写入 Mask。

half4 FringeShadowCasterFrag(FringeShadowCasterVaryings input) : SV_Target
{
// hairuber = 0,会被丢弃
// fringeuber = 1,会写入 Mask
clip(_UseAsFringeShadowCaster - 0.5);

return half4(_FringeCasterOpacity, 0, 0, 1);
}

头发 Shader 生成 _GF2FringeShadowTex 后,Face Shader 用屏幕坐标采样这张图

顶点阶段写入:

output.screenPos = ComputeScreenPos(output.positionCS);

片元阶段接收刘海阴影:

// 屏幕 UV
float2 screenUV = input.screenPos.xy / input.screenPos.w;

// 读取刘海投影 Mask
half fringeRaw = SAMPLE_TEXTURE2D(
_GF2FringeShadowTex,
sampler_GF2FringeShadowTex,
screenUV
).r;

// 阈值化,控制阴影范围和边缘
half fringeShadow = smoothstep(
_FringeShadowThreshold,
_FringeShadowThreshold + _FringeShadowSoftness,
fringeRaw
);

// 让刘海阴影可选择受脸部当前受光程度影响
half fringeLightMask =
lerp(1.0, visibleFaceLight, _FringeShadowLightInfluence);

fringeShadow = saturate(
fringeShadow *
fringeLightMask *
_FringeShadowStrength *
_UseFringeShadow
);

// 压暗脸部颜色
finalNoMatCap = lerp(
finalNoMatCap,
finalNoMatCap * _FringeShadowColor.rgb,
fringeShadow
);

2.衣物 & 身体皮肤 渲染 (Base)

衣物和皮肤(身体)是一样的,只是Ramp Texture有所不同, 以下以衣物为例

注意:这里SSS的渲染效果信息明显已经被存进Ramp图里了,所以衣物和皮肤可以一起渲染

RMO
  • 第一行:额外直射光的漫反射阴影 Ramp
    第二行:环境反射 / IBL 高光 Ramp
    第三行:主光和额外光的直接高光 Ramp
    第四行:主光直射漫反射阴影 Ramp

漫反射:N·L + Shadow → Ramp(主,额外光高光计算都调用该函数)

  • 漫反射先根据 N·L 和阴影算出受光强度,再用 Ramp 控制亮部、暗部、过渡色。
half NoL = saturate(dot(normalWS, L));
half NoL01 = saturate(NoL * 0.5 + 0.5);

half shadowAtten = lerp(1.0, lightData.shadowAttenuation, _ReceiveShadowStrength);
half lightAtten = lightData.distanceAttenuation * shadowAtten;

half rampInput = saturate(NoL01 * lightAtten);
half3 rampColor = SampleRamp(rampInput, rampV);

half3 rampDiffuse =
    albedo *
    rampColor *
    lightData.color.rgb *
    lightStrength *
    _RampDiffuseStrength;

half3 ComputeRampDiffuse(
    half3 albedo,
    half3 normalWS,
    Light lightData,
    half rampV,
    half lightStrength,
    half ao,
    half isMainLight,
    out half toonLightOut
)
{
    half3 L = normalize(lightData.direction);

    // N·L:表面朝向光源的程度
    half NoL = saturate(dot(normalWS, L));

    // Remap 到 0~1,让背光面也能保留一点 Ramp 变化
    half NoL01 = saturate(NoL * 0.5 + 0.5);

    // 阴影影响
    half shadowAtten = lerp(1.0, lightData.shadowAttenuation, _ReceiveShadowStrength);
    half lightAtten = lightData.distanceAttenuation * shadowAtten;

    // Toon 明暗分界
    half directTerm = NoL * lightAtten;

    half toonLight = smoothstep(
        _ShadowThreshold - _ShadowSoftness,
        _ShadowThreshold + _ShadowSoftness,
        directTerm
    );

    toonLightOut = toonLight;

    // AO 对直接光的影响
    half directAO = lerp(1.0, ao, _AODirectStrength);

    // 没开 Ramp 时的备用漫反射
    half3 fallback =
        albedo *
        lightData.color.rgb *
        lerp(0.35, 1.0, toonLight) *
        lightStrength *
        directAO;

    // 用 N·L + Shadow 去采 Ramp
    half rampInput = saturate(NoL01 * lightAtten);
    half3 rampColor = SampleRamp(rampInput, rampV);

    half3 rampDiffuse =
        albedo *
        rampColor *
        lightData.color.rgb *
        lightStrength *
        _RampDiffuseStrength *
        directAO;

    // 暗部保留基础色,避免全黑
    half darkKeep = saturate((1.0 - toonLight) * _DarkKeepStrength);
    rampDiffuse += albedo * rampColor * darkKeep;

    return lerp(fallback, rampDiffuse, _UseRamp);
}

GGX BRDF 高光(主,额外光高光计算都调用该函数)

  • 没有采用Blinn-Phong,而是用 GGX 的 D / G / F 三项计算,更接近 PBR(效果也更接近游戏内效果)
  • 然后高光也走 Ramp:
half3 ComputeGGXDirectSpecular(
    half3 albedo,
    half3 normalWS,
    half3 viewDirWS,
    Light lightData,
    half roughness,
    half metallic,
    half rampV,
    half lightStrength
)
{
    half3 L = normalize(lightData.direction);
    half3 V = normalize(viewDirWS);
    half3 H = normalize(L + V);

    half NoL = saturate(dot(normalWS, L));
    half NoV = saturate(dot(normalWS, V));
    half NoH = saturate(dot(normalWS, H));
    half HoV = saturate(dot(H, V));

    half shadowAtten = lerp(1.0, lightData.shadowAttenuation, _ReceiveShadowStrength);
    half lightAtten = lightData.distanceAttenuation * shadowAtten;

    // 非金属 F0 通常约等于 0.04,金属使用 albedo 作为反射色
    half3 F0 = lerp(_DielectricF0.xxx, albedo, metallic);

    // GGX BRDF 三项:D、G、F
    half D = DistributionGGX(NoH, roughness);
    half G = GeometrySmith(NoV, NoL, roughness);
    half3 F = FresnelSchlick(HoV, F0);

    half3 numerator = D * G * F;
    half denominator = max(4.0 * NoV * NoL, 0.001);

    half3 specBRDF = numerator / denominator;
    specBRDF = min(specBRDF, _SpecClamp.xxx);

    // 决定用什么值去采 Spec Ramp
    half specRampInputByBRDF = saturate(Luma(specBRDF) * _SpecRampInputScale);

    half specRampInput = NoL;
    specRampInput = lerp(specRampInput, NoH, step(0.5, _SpecRampInputMode));
    specRampInput = lerp(specRampInput, specRampInputByBRDF, step(1.5, _SpecRampInputMode));

    half3 specRamp = SampleRamp(specRampInput, rampV);

    half3 specColor =
        specBRDF *
        NoL *
        lightData.color.rgb *
        lightAtten *
        lightStrength *
        _SpecStrength *
        _RampSpecStrength *
        _SpecColor.rgb *
        specRamp;

    return specColor;
}
  • NoL:决定是否被光照到
  • lightAtten= 光照衰减和阴影衰减。
    如果像素在阴影里,或者离点光源太远,这个值会变小
  • specRamp= 从 Ramp Texture 的高光行采样出来的颜色。
    它决定高光的二游风格颜色变化

Additional Light 额外灯光 额外灯光也参与漫反射和高光,不只是主光源。

#if defined(_ADDITIONAL_LIGHTS)
    uint additionalLightCount = GetAdditionalLightsCount();

    for (uint lightIndex = 0u; lightIndex < additionalLightCount; lightIndex++)
    {
        Light addLight = GetAdditionalLight(lightIndex, input.positionWS);

        additionalDiffuse += ComputeRampDiffuse(
            albedo,
            normalWS,
            addLight,
            _AdditionalShadowRampV,
            _AdditionalLightStrength,
            ao,
            0.0,
            addToonLight
        );

        additionalSpec += ComputeGGXDirectSpecular(
            albedo,
            normalWS,
            viewDirWS,
            addLight,
            roughness,
            metallic,
            _DirectSpecRampV,
            _AdditionalLightStrength
        );
    }
#endif

IBL / EnvCube 环境反射(只做了Specular光的IBL,是简化版本)

  • EnvCube 是 IBL 的实现入口,用环境贴图提供反射信息
  • 根据当前的观察方向和点的法向量计算反射方向,然后用反射方向去环境贴图中采样
  • 环境反射再经过 Fresnel 和 Env Ramp:
// 计算 EnvCube / IBL 环境镜面反射
half3 ComputeEnvSpecular(
    half3 albedo,       // 材质基础颜色
    half3 normalWS,     // 世界空间法线
    half3 viewDirWS,    // 世界空间视线方向
    half roughness,     // 粗糙度,越大反射越模糊
    half metallic       // 金属度,越高反射越接近 albedo 颜色
)
{
    // N·V:法线和视线方向夹角
    // 用来控制 Fresnel,边缘视角反射更强
    half NoV = saturate(dot(normalWS, viewDirWS));

    // F0:基础反射率
    // 非金属通常约为 0.04,金属使用自身颜色作为反射色
    half3 F0 = lerp(_DielectricF0.xxx, albedo, metallic);

    // Fresnel-Schlick Roughness 版本
    // 让边缘反射更强,同时受 roughness 影响
    half3 F = FresnelSchlickRoughness(NoV, F0, roughness);

    // 计算反射方向
    // 用视线方向和法线得到从环境贴图中采样的方向
    half3 reflectDir = reflect(-viewDirWS, normalWS);

    // 根据 roughness 选择 Cubemap 的 mip 层级
    // roughness 越高,mip 越高,反射越模糊
    half mip = roughness * _EnvMipScale * _EnvRoughnessInfluence;

    // 从 EnvCube / Cubemap 中采样环境反射颜色
    half3 envCol = SAMPLE_TEXTURECUBE_LOD(
        _EnvCube,
        sampler_EnvCube,
        reflectDir,
        mip
    ).rgb;

    // 额外的 Fresnel 强度控制
    // 越靠近模型边缘,fresnelTerm 越强
    half fresnelTerm =
        pow(saturate(1.0 - NoV), _EnvFresnelPower) *
        _EnvFresnelStrength;

    // Env Ramp 的采样输入
    // NoV:正面更亮
    // 1 - NoV:边缘更亮
    half envRampInputA = NoV;
    half envRampInputB = saturate(1.0 - NoV);

    // 通过 _EnvRampInvert 选择 Ramp 方向
    half envRampInput = lerp(envRampInputA, envRampInputB, _EnvRampInvert);

    // 采样 Env Specular Ramp 行
    // 默认是 Row 1 / V = 0.375
    half3 envRamp = SampleRamp(envRampInput, _EnvSpecRampV);

    // 粗糙度控制环境反射强弱
    // 粗糙度越高,反射越弱
    half roughMask = lerp(1.0, 1.0 - roughness, _EnvRoughnessInfluence);

    // 金属区域增强环境反射
    half metalBoost = lerp(1.0, _EnvMetallicBoost, metallic);

    // 最终环境镜面反射
    half3 envSpec =
        envCol *                 // 环境贴图颜色
        F *                      // Fresnel 反射率
        envRamp *                // Ramp 风格化控制
        _EnvStrength *           // 环境反射总强度
        (0.35 + fresnelTerm) *   // 基础反射 + 边缘 Fresnel 增强
        roughMask *              // 粗糙度影响
        metalBoost;              // 金属增强

    return envSpec;
}

MatCap 美术反光

  • MatCap 不属于物理 IBL,而是额外的美术反光层,用来快速做出二游亮面效果
float3 normalVS = mul((float3x3)UNITY_MATRIX_V, normalWS);

float2 uv = normalVS.xy * 0.5 * _MatCapScale + 0.5;
uv.x += _MatCapOffsetX;
uv.y += _MatCapOffsetY;
  • 采样并叠加:
half3 matcapTex = SAMPLE_TEXTURE2D(
    _MatCapTex,
    sampler_MatCapTex,
    matcapUV
).rgb;

half3 matcap =
    matcapTex *
    _MatCapColor.rgb *
    _MatCapStrength *
    (1.0 + matcapFresnel) *
    matcapLightMask *
    matcapRoughMask;

Rim 边缘光

  • 强化模型边缘层次,不是描边,而是材质内部的边缘提亮。
half NoV = saturate(dot(normalWS, viewDirWS));

half rimMask = pow(saturate(1.0 - NoV), _RimPower);
half rimLightMask = lerp(1.0, toonLight, _RimLightInfluence);

return _RimColor.rgb * rimMask * _RimStrength * rimLightMask;
最终颜色 =
ambient
+ mainDiffuse
+ additionalDiffuse
+ mainSpec
+ additionalSpec
+ envSpec
+ rim
+ matcap

3.Face

脸部主要有如下三种贴图

  • SDF Map
    • R: Face Diffuse Shadow SDF
    • G: Face Specular SDF 1
    • B: Face Specular SDF 2
    • A: Face Mask
  • Diffuse Map
  • Ramp Texture

这里面最重要的贴图就是SDF贴图了

用主光方向和 Face SDF 贴图比较,生成随光源旋转变化的脸部阴影;再用 Ramp 控制脸部阴影颜色,用 G/B 通道控制鼻尖和嘴唇高光,最后用暗部环境补光和可选 MatCap 增强二游质感

根据主光方向判断脸部受光方向

// 获取主光
float4 shadowCoord = TransformWorldToShadowCoord(input.positionWS);
Light mainLight = GetMainLight(shadowCoord);

half3 lightDirWS = normalize(mainLight.direction);

// 只取 XZ 平面判断脸部左右受光,避免头部法线影响脸部阴影形状
float2 lightXZ = SafeNormalize2D(lightDirWS.xz);

// 将脸的前方、右方从物体空间转到世界空间
float3 faceForwardWS3 =
    normalize(TransformObjectToWorldDir(_FaceForwardOS.xyz) * _FaceForwardSign);

float3 faceRightWS3 =
    normalize(TransformObjectToWorldDir(_FaceRightOS.xyz) * _FaceRightSign);

float2 faceForwardXZ = SafeNormalize2D(faceForwardWS3.xz);
float2 faceRightXZ = SafeNormalize2D(faceRightWS3.xz);

// 判断光源在脸左侧还是右侧
half autoFaceSideDot = dot(lightXZ, faceRightXZ);

// 判断光源相对脸正面的程度
half autoFaceFrontDot = saturate(
    dot(-lightXZ, faceForwardXZ) * 0.5 + _ShadowOffset
);

// 手动调试模式,可直接覆盖自动光照方向
half faceSideDot = lerp(autoFaceSideDot, _ManualFaceSide, _UseManualFaceLight);
half faceFrontDot = lerp(autoFaceFrontDot, _ManualFaceFront, _UseManualFaceLight);
  • Face Forward Sign:修正正面 / 背面光照方向
  • Face Right Sign:修正左 / 右光照方向
  • Shadow Offset:控制阴影出现的角度偏移
  • Use Manual Face Light:手动测试脸部光照方向

左右 SDF 平滑切换

如果直接用 step() 切换左右 SDF,光源从左转到右时会突然跳变。
所以这里用 smoothstep() 做平滑过渡。

// 左右 SDF 平滑切换,避免 X 轴旋转时阴影跳变
half sideBlend = smoothstep(
    -_SideSwitchSmooth,
    _SideSwitchSmooth,
    faceSideDot
);

采样 Face SDF 贴图

  • R:脸部阴影 SDF
    G:鼻尖高光数据
    B:嘴唇高光数据
    A:阴影 Mask
// 选择 SDF 使用 UV0 或 UV1
float2 sdfUV0 = TRANSFORM_TEX(input.uv0, _FaceSDFMap);
float2 sdfUV1 = TRANSFORM_TEX(input.uv1, _FaceSDFMap);

float2 sdfUV = lerp(sdfUV0, sdfUV1, step(0.5, _FaceSDFUVSet));

// 修正 SDF 贴图方向
sdfUV.x = lerp(sdfUV.x, 1.0 - sdfUV.x, _SDFInitialFlipX);
sdfUV.y = lerp(sdfUV.y, 1.0 - sdfUV.y, _SDFInvertY);

// 右侧光使用原图,左侧光使用镜像图
float2 sdfUV_R = sdfUV;
float2 sdfUV_L = sdfUV;
sdfUV_L.x = 1.0 - sdfUV_L.x;

half4 faceMap_R = SAMPLE_TEXTURE2D(_FaceSDFMap, sampler_FaceSDFMap, sdfUV_R);
half4 faceMap_L = SAMPLE_TEXTURE2D(_FaceSDFMap, sampler_FaceSDFMap, sdfUV_L);

// R 通道:脸部阴影 SDF
half sdfR = faceMap_R.r;
half sdfL = faceMap_L.r;

// 调整 SDF 灰度范围
sdfR = saturate((sdfR - 0.5) * _SDFValueScale + 0.5 + _SDFValueOffset);
sdfL = saturate((sdfL - 0.5) * _SDFValueScale + 0.5 + _SDFValueOffset);

// 根据光源左右方向混合左右 SDF
half selectedSDF = lerp(sdfL, sdfR, sideBlend);

// A 通道:阴影 Mask
half shadowMaskR = lerp(1.0, faceMap_R.a, _ShadowMaskStrength);
half shadowMaskL = lerp(1.0, faceMap_L.a, _ShadowMaskStrength);
half selectedShadowMask = lerp(shadowMaskL, shadowMaskR, sideBlend);
  • SDFInitialFlipX / SDFInvertY:修贴图方向
    SDFValueOffset / SDFValueScale:调整 SDF 阈值范围
    ShadowMaskStrength:控制 A 通道遮罩参与程度

用 SDF 计算脸部阴影

// 光越偏侧面,SDF 阴影边界越明显
// Shadow Smooth 控制阴影边缘软硬
half sdfSmoothFixed = max(
    0.001,
    saturate(1.0 - faceFrontDot) * _ShadowSmooth
);

// 用当前光照方向和 SDF 灰度比较,得到受光区域
half faceLitBySDF = smoothstep(
    selectedSDF,
    selectedSDF + sdfSmoothFixed,
    faceFrontDot
);

// 必要时反转明暗结果
faceLitBySDF = lerp(faceLitBySDF, 1.0 - faceLitBySDF, _InvertSDFResult);

// 真正的脸部阴影权重
half sdfShadowWeight =
    saturate(1.0 - faceLitBySDF) *
    selectedShadowMask *
    _FaceShadowStrength;

// 可见受光程度
half visibleFaceLight = saturate(1.0 - sdfShadowWeight);
  • selectedSDF:决定阴影形状
    faceFrontDot:决定当前光照角度
    Shadow Smooth:控制阴影边缘
    Face Shadow Strength:控制阴影强度
    visibleFaceLight:最终用于 Ramp 上色

Ramp 控制脸部明暗颜色

脸部不是简单变黑,而是把 SDF 光照结果送进 Ramp,得到二游风格的肤色阴影

// 普通 N·L 只辅助 Ramp 采样,不直接决定脸部阴影形状
half nDotL = saturate(dot(normalWS, lightDirWS));
half nDotLForRamp = saturate(nDotL * 0.75 + 0.25);

// 用 SDF 受光程度采样 Ramp
half shadowRampInput = saturate(visibleFaceLight * nDotLForRamp);

half3 rampCol = SampleFaceRamp(shadowRampInput, _FaceShadowRampV);

// Ramp 漫反射,受 Main Light Strength 控制
half3 rampDiffuse =
    albedo *
    rampCol *
    _RampStrength *
    mainLight.color.rgb *
    _LightStrength;

鼻尖 / 嘴唇高光

这版高光直接读取 Face SDF 图的 G/B 通道:

// G = 鼻尖高光数据,B = 嘴唇高光数据
half selectedNoseData = lerp(faceMap_L.g, faceMap_R.g, sideBlend);
half selectedMouthData = lerp(faceMap_L.b, faceMap_R.b, sideBlend);

// 光照方向对高光的影响
half specLightMask =
    lerp(1.0, smoothstep(0.05, 0.95, faceFrontDot), _FaceSpecLightInfluence);

// 鼻尖高光:只读 G 通道
half noseSpecMask =
    smoothstep(
        _NoseSpecThreshold,
        _NoseSpecThreshold + _NoseSpecSoftness,
        selectedNoseData
    ) *
    specLightMask *
    _UseFaceSpec;

// 嘴唇高光:只读 B 通道
half mouthSpecMask =
    smoothstep(
        _MouthSpecThreshold,
        _MouthSpecThreshold + _MouthSpecSoftness,
        selectedMouthData
    ) *
    specLightMask *
    _UseFaceSpec;

// 高光也采样 Ramp
half specRampInput = saturate(max(noseSpecMask, mouthSpecMask) * nDotLForRamp);
half3 specRamp = SampleFaceRamp(specRampInput, _FaceSpecRampV);

// 鼻尖高光
half3 noseSpec =
    noseSpecMask *
    _NoseSpecColor.rgb *
    _NoseSpecStrength *
    specRamp *
    _LightStrength;

// 嘴唇高光
half3 mouthSpec =
    mouthSpecMask *
    _MouthSpecColor.rgb *
    _MouthSpecStrength *
    specRamp *
    _LightStrength;
  • Nose / Mouth Spec Threshold:控制高光出现范围
  • Nose / Mouth Spec Softness:控制高光边缘软硬
  • Nose / Mouth Spec Strength:控制高光强度
  • Face Spec Light Influence:控制鼻嘴高光受光照方向影响程度

最终合成

half3 finalNoMatCap =
    ambient +
    diffuse +
    noseSpec +
    mouthSpec +
    envSpec;

half3 finalColor = BlendAddOrScreen(
    finalNoMatCap,
    matcap,
    _MatCapBlendMode
);

finalColor *= _CharacterTint.rgb * _CharacterBrightness;

// 轻微软压亮面,避免脸部过曝
finalColor = finalColor / (1.0 + finalColor * 0.18);

return half4(finalColor, baseTex.a * _BaseColor.a);

4.Eye

(53 封私信 / 8 条消息) 二次元角色卡通渲染—眼睛篇 – 知乎

二次元角色卡通渲染—眼睛篇 – 知乎

使用Maya拆开整个眼睛的渲染,可以发现整个眼睛主要有这五层

  • 橙色部分: 眉毛 & 眼睫毛
  • 蓝色部分:眼白
  • 绿色部分: 眼球(凹下去的结构,利用视差体现眼睛的“深邃感”)
  • 红色部分: 眼睛高光
  • 黄色部分: 眼睛阴影

贴图主要有以下三张:

  • 第1张贴图:包含了眼睛高光和眼睛阴影信息
  • 第2张贴图:眼球
  • 第3张贴图:眼白和眉毛(和脸部共用一张Diffuse贴图)

眼白,眉毛,和眼球就是使用正常BSDF进行渲染即可
其中眼球的“深邃感”(视差效果), 是靠凹面结构做到的

而眼睛高光和眼睛的阴影则是使用半透明材质进行叠加

在 Unity Shader 中,半透明材质混合公式如下:

Eye Specular的设置为Blend One One
最终半透明混合公式为:

Eye Shadow的设置为Blend DstColor Zero
最终半透明混合公式为

Eye Base:眼球主体 + 视差

负责显示眼球颜色,并通过视角方向偏移 UV,模拟眼球凹陷和虹膜深度

核心思路:

视线方向 viewDir
→ 转到物体空间
→ 取 xy 作为 UV 偏移
→ 只在虹膜区域启用偏移
→ 采样眼球贴图

关键代码:

float2 GetParallaxUV(float2 uv, float3 positionWS)
{
// 当前像素到相机的方向
float3 viewDirWS = normalize(GetWorldSpaceViewDir(positionWS));

// 转到物体空间,让偏移方向跟眼球局部方向一致
float3 viewDirOS = normalize(TransformWorldToObjectDir(viewDirWS));

// 用视线方向的 xy 作为 UV 偏移方向
float2 offset = viewDirOS.xy;

// 用于修正不同模型 UV / 朝向导致的反向问题
offset.x *= lerp(1.0, -1.0, _InvertParallaxX);
offset.y *= lerp(1.0, -1.0, _InvertParallaxY);

// 只让虹膜区域发生视差,眼白区域不偏移
half mask = GetIrisMask(uv);

float2 parallaxUV = uv + offset * _ParallaxScale;

return lerp(uv, parallaxUV, mask);
}

虹膜 Mask:

half GetIrisMask(float2 uv)
{
float2 center = _IrisCenter.xy;
float2 radius = max(_IrisRadius.xy, 0.0001);

// 把 UV 转成以虹膜中心为原点的椭圆区域
float2 local = (uv - center) / radius;
float dist2 = dot(local, local);

// 越靠近虹膜中心,mask 越接近 1
half mask = smoothstep(
_ParallaxMaskEdge,
_ParallaxMaskEdge + _ParallaxMaskSoftness,
1.0 - dist2
);

return saturate(mask);
}

最终采样:

float2 rawUV = TRANSFORM_TEX(input.uv, _BaseMap);
float2 parallaxUV = GetParallaxUV(rawUV, input.positionWS);

// 用偏移后的 UV 采样眼球贴图
half4 baseTex = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, parallaxUV);

Eye Add:眼睛高光叠加

用来叠加眼睛高光、亮斑、眼球光晕。它使用加法混合:

最终颜色 = 原画面颜色 + 当前高光颜色 × Alpha

Blend One One 更可控,因为高光强度可以被 Alpha / Mask 控制,不容易爆白。

关键代码:

half4 addTex = SAMPLE_TEXTURE2D(_AddMap, sampler_AddMap, uv);

// 从指定通道读取高光 Mask
half maskValue = GetMaskValue(addTex);

// 阈值化,控制高光范围和边缘软硬
half mask = smoothstep(
_MaskThreshold,
_MaskThreshold + _MaskSoftness,
maskValue
);

// 限制高光只出现在虹膜区域
half irisLimit = lerp(1.0, GetIrisMask(input.uv), _UseIrisLimit);

// 最终 Alpha 控制高光叠加强度
half alpha = mask * irisLimit * _Opacity * _AddColor.a;

// 高光颜色
half3 col = addTex.rgb * _AddColor.rgb * _AddStrength;

return half4(col, alpha);

高光也带视差:

float2 GetParallaxUV(float2 uv, float3 positionWS)
{
float3 viewDirWS = normalize(GetWorldSpaceViewDir(positionWS));
float3 viewDirOS = normalize(TransformWorldToObjectDir(viewDirWS));

float2 offset = viewDirOS.xy;

offset.x *= lerp(1.0, -1.0, _InvertParallaxX);
offset.y *= lerp(1.0, -1.0, _InvertParallaxY);

half irisMask = GetIrisMask(uv);

// 高光只在虹膜区域随视角轻微偏移
return uv + offset * _ParallaxScale * irisMask;
}

Eye Multiply:眼睛阴影乘法压暗

用来做上眼睑阴影、眼球阴影、眼睛暗部压色。它使用乘法混合:

最终颜色 = 当前输出颜色 × 已经画好的颜色

也就是说,Shader 输出白色时不影响画面,输出暗色时压暗底下的眼睛。

关键代码:

half4 shadowTex = SAMPLE_TEXTURE2D(_ShadowMap, sampler_ShadowMap, uv);

// 从指定通道读取阴影 Mask
half maskValue = GetMaskValue(shadowTex);

// 阈值化,控制阴影范围
half mask = smoothstep(
_MaskThreshold,
_MaskThreshold + _MaskSoftness,
maskValue
);

// 可选:用 UV.y 限制阴影上下区域
half vertical = smoothstep(_VerticalStart, _VerticalEnd, uv.y);
vertical = lerp(vertical, 1.0 - vertical, _InvertVertical);

mask *= lerp(1.0, vertical, _UseVerticalLimit);

// 阴影强度
half shadowAmount = saturate(mask * _ShadowStrength);

// 白色 = 不改变底色
// ShadowColor = 压暗底色
half3 multiplyColor = lerp(1.0.xxx, _ShadowColor.rgb, shadowAmount);

return half4(multiplyColor, 1);

Eyelash:睫毛透明 / 裁切

GF2_Eyelash_Alpha 用来画睫毛。它使用普通透明混合:

Blend SrcAlpha OneMinusSrcAlpha

并且支持 Alpha Clip,减少透明排序问题。

关键代码:

half4 baseTex = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv);

// 睫毛透明度
half alpha = baseTex.a * _TintColor.a * _AlphaStrength * _Opacity;

// Alpha Clip:低于阈值的部分直接丢弃
// 可以减少透明边缘发灰、排序错误
if (_UseAlphaClip > 0.5)
clip(alpha - _Cutoff);

// 颜色可以使用贴图颜色,也可以只用 Tint
half3 texColor = baseTex.rgb;
half3 color = lerp(
_TintColor.rgb,
texColor * _TintColor.rgb,
_UseTextureColor
);

return half4(color, alpha);

顶点阶段有一个 Z 偏移:

output.positionCS = posInput.positionCS;

// 用来微调睫毛深度,解决和眼睛层互相穿插的问题
output.positionCS.z += _ZOffset;

6. 渲染顺序

通过 Shader 里的 Queue 标签决定顺序的,当前分层顺序是:

Eye BaseParallax:Transparent
Eye Multiply:Transparent+10
Eye Add:Transparent+20
Eyelash Alpha:Transparent+30

含义:

先画眼球主体
再乘法压暗眼睛阴影
再加法叠加高光
最后画睫毛

这种分层方式优点是调试清楚,每一层都能独立控制。缺点是透明层多,后期可能有排序风险。

5.Stock

(53 封私信 / 14 条消息) 少女前线2渲染还原分析—以Cheeta为例 – 知乎

丝袜的渲染其实大部分和衣物的渲染是一样的,只是在高光的计算上有所不同,丝袜和头发一样, 因为其物理构造所以具有会产生各向异性高光的性质, 参考图中,我们可以观察到丝袜具有明显的拉丝高光。所以在这里不使用普通的GGX高光而是使用各向异性GGX高光

  • 其实本身丝袜材质还有一定的“透肉”效果, 而在少前2中,这里是本身已经将这种效果烘焙进Diffuse贴图里(如何实现的?这得拿什么软件做,什么方式)
    • 少女前线的这个丝袜是不是“真实覆盖在身体外面的一层透明模型”?
      如果是 → 用 Transparent 版。
      如果不是,只是贴图里画了透肉效果 → 用 Opaque 假透肉版更稳。

核心思路:

1. 透肉不是物理透明,而是把皮肤色混进 Diffuse。
2. 丝袜高光不是普通 GGX,而是各向异性 GGX,高光会沿纤维方向拉长

假透肉 + 边缘加深

丝袜并没有真的透明, 不是物理 SSS,而是用 Diffuse 混色近似“透肉感”

而是在基础色里混入皮肤色,再叠丝袜本身的颜色。边缘视角处再加深,模拟丝袜包裹在腿上的厚度变化

// 采样丝袜基础贴图
half4 baseTex = SAMPLE_TEXTURE2D(
    _BaseMap,
    sampler_BaseMap,
    TRANSFORM_TEX(input.uv, _BaseMap)
);

// 原始贴图颜色
half3 baseAlbedo = baseTex.rgb * _BaseColor.rgb;

// ndotv:法线和视线方向夹角
// 正面看 ndotv 高,边缘看 ndotv 低
half ndotv = saturate(dot(normalWS, viewDirWS));

// 假透肉:把丝袜基础色往皮肤色方向混合
// _SkinThroughStrength 越高,越像能看到皮肤
half3 skinMixed = lerp(
    baseAlbedo,
    baseAlbedo * _SkinThroughColor.rgb,
    _SkinThroughStrength
);

// 叠加丝袜本身颜色
// _StockingDensity 越高,丝袜颜色越重
half3 albedo = lerp(
    skinMixed,
    skinMixed * _StockingTint.rgb,
    _StockingDensity
);

// 边缘加深:视线越贴边缘,丝袜越暗
half viewDark =
    pow(saturate(1.0 - ndotv), _ViewDarkenPower) *
    _ViewDarkenStrength;

// 用丝袜颜色压暗边缘区域
albedo = lerp(
    albedo,
    albedo * _StockingTint.rgb * 0.65,
    viewDark
);
  • SkinThroughColor:透出来的皮肤颜色
    StockingTint:丝袜本身颜色
    StockingDensity:越高,丝袜颜色越重
    ViewDarkenStrength:边缘变暗强度
    ViewDarkenPower:边缘变暗范围

各向异性方向轴

  • 普通高光是圆形的,丝袜高光应该沿纤维方向拉长,所以要用 Tangent / Bitangent 作为高光方向轴
// 计算各向异性高光方向
void GetAnisoFrame(
    half3 normalWS,      // 世界空间法线
    half3 tangentWS,     // 世界空间切线
    half3 bitangentWS,   // 世界空间副切线
    out half3 anisoT,    // 各向异性主方向
    out half3 anisoB     // 各向异性副方向
)
{
    // 可以选择 Tangent 或 Bitangent 作为主方向
    half3 baseT = lerp(tangentWS, bitangentWS, step(0.5, _AnisoDirection));
    half3 baseB = lerp(bitangentWS, tangentWS, step(0.5, _AnisoDirection));

    baseT = normalize(baseT);
    baseB = normalize(cross(normalWS, baseT));

    // 翻转高光方向
    baseT = lerp(baseT, -baseT, _AnisoFlip);

    // 手动旋转高光方向,修正模型切线不准的问题
    float angle = _AnisoRotation * 0.01745329252;
    float s = sin(angle);
    float c = cos(angle);

    anisoT = normalize(c * baseT + s * baseB);
    anisoB = normalize(-s * baseT + c * baseB);
}

这几个参数很重要,因为模型 UV / 切线方向不一定和丝袜纹理方向一致。

  • Aniso Direction:选择切线或副切线作为高光方向
  • Aniso Flip:翻转方向
  • Aniso Rotation:旋转高光方向

各向异性粗糙度轴

原理:各向异性高光不是一个统一 roughness,而是把粗糙度拆成两个方向:axay。一个方向更光滑,高光就会被拉长

// 根据 roughness 和 anisotropy 计算两个方向的粗糙度
void GetAnisoAxes(
float roughness,
float anisotropy,
out float ax,
out float ay
)
{
// 各向异性强度,范围 0~1
float anisoAbs = saturate(abs(anisotropy));

// aspect 控制两个方向粗糙度比例
float aspect = sqrt(max(1.0 - 0.9 * anisoAbs, 0.0001));

// GGX 常用 alpha = roughness²
float alpha = max(
    roughness * roughness,
    _SpecMinRoughness * _SpecMinRoughness
);

// 两个方向的粗糙度
ax = max(0.0001, alpha / aspect);
ay = max(0.0001, alpha * aspect);

// anisotropy 为负时交换方向
if (anisotropy < 0.0)
{
    float tmp = ax;
    ax = ay;
    ay = tmp;
}

}

  • Anisotropy = 0:普通圆形高光
  • Anisotropy 越接近 1:高光越被拉长
  • Spec Min Roughness:限制高光最小宽度,避免过尖

各向异性 GGX 高光核心

它仍然是 PBR 的 GGX 高光,但 D 和 G 都变成各向异性版本

// 各向异性 GGX 的 D 项
// D 控制微表面法线分布,也就是高光形状
float D_GGX_Aniso(
    float NoH,
    float HoT,
    float HoB,
    float ax,
    float ay
)
{
    float ax2 = ax * ax;
    float ay2 = ay * ay;

    float denom =
        (HoT * HoT) / ax2 +
        (HoB * HoB) / ay2 +
        (NoH * NoH);

    return 1.0 / max(GF2_PI * ax * ay * denom * denom, 0.000001);
}

// 各向异性 Smith GGX 的单方向 G1
// G 控制视线/光线被微表面遮挡的程度
float G1_SmithGGX_Aniso(
    float NoX,
    float XoT,
    float XoB,
    float ax,
    float ay
)
{
    if (NoX <= 0.0)
        return 0.0;

    float NoX2 = max(NoX * NoX, 0.000001);

    float t =
        ax * ax * XoT * XoT +
        ay * ay * XoB * XoB;

    float lambda = (sqrt(1.0 + t / NoX2) - 1.0) * 0.5;

    return 1.0 / (1.0 + lambda);
}

MatCap / Fresnel 补充反光

这部分不是物理核心,但很适合二游丝袜效果。它可以补一点稳定的亮边和丝质反光。

// Fresnel:边缘视角增强
half fresnel = pow(saturate(1.0 - ndotv), _FresnelPower);

half3 fresnelCol =
    _FresnelColor.rgb *
    fresnel *
    _FresnelStrength;

// MatCap:用视角空间法线采样球形反光贴图
float2 matcapUV = GetMatCapUV(normalWS);

half3 matcapTex = SAMPLE_TEXTURE2D(
    _MatCapTex,
    sampler_MatCapTex,
    matcapUV
).rgb;

// MatCap 也加 Fresnel,让边缘更明显
half matcapFresnel =
    pow(saturate(1.0 - ndotv), _MatCapFresnelPower) *
    _MatCapFresnelStrength;

half smoothness = 1.0 - roughness;

// roughness 越低,MatCap 越明显
half matcapRoughMask =
    lerp(1.0, smoothness, _MatCapRoughnessInfluence);

// MatCap 也经过 Ramp 控制
half3 matcapRamp = SampleRamp(matcapFresnel, _EnvSpecRampV);

half3 matcap =
    matcapTex *
    matcapRamp *
    _MatCapColor.rgb *
    _MatCapStrength *
    (0.35 + matcapFresnel) *
    matcapRoughMask;
  • Fresnel:让边缘有丝袜光泽
    MatCap:补稳定的丝质反光
    MatCap Roughness Influence:粗糙度越高,MatCap 越弱

最终合成

// 没有高光前的基础颜色
half3 finalNoSpec =
    ambient +
    directDiffuse +
    addDiffuse;

// 最终颜色
half3 finalColor =
    finalNoSpec +     // 环境光 + 漫反射
    mainSpec +        // 主光各向异性高光
    addSpec +         // 额外光各向异性高光
    fresnelCol +      // Fresnel 边缘光泽
    matcap +          // MatCap 丝质反光
    envSpec;          // EnvCube 环境反射

// AO 统一压暗缝隙和遮蔽区域
finalColor *= ao;

return half4(finalColor, 1);

丝袜 Shader 的核心是:用 Diffuse 混色模拟“透肉”,用视角边缘加深模拟厚度变化,再用各向异性 GGX 根据切线方向生成被拉长的丝状高光,最后通过 Ramp、Fresnel、MatCap 做二游化增强。

角色演示视频链接:

https://www.233leyuan.com/post-detail/2066142338856747008

学习笔记如有侵权,请提醒我,我会马上删除
暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇