GameTech/URP 渲染实战
源码 / shaders/Ch14/ToonCommon.glsl
shaders/Ch14/ToonCommon.glsl
// 第 14 章卡通渲染的公共代码:顶点结构、色阶、边缘光、风格化高光、描边顶点外扩。
// 各个案例 Shader 与最终的 ToonLit 都 include 它。(GLSL 版,对应 Ch14/ToonCommon.hlsl)
// GLSL 版的用法:着色 Pass 先 #define TOON_VARYINGS 再 include,得到 ToonVaryings 对应的 varying 变量和 ToonVert();
// 描边 Pass 不定义它,只用下面的函数。
#ifndef COOKBOOK_CH14_TOON_COMMON_INCLUDED
#define COOKBOOK_CH14_TOON_COMMON_INCLUDED

#ifdef TOON_VARYINGS
// ToonVaryings(WebGL 不允许结构体做 varying,拆成单独的变量)
VARYING vec2  uv;
VARYING vec3  positionWS;
VARYING vec3  normalWS;
VARYING vec4  tangentWS;
VARYING vec3  objectPositionOS;   // 脸部 SDF 用物体空间坐标做投影 UV(顶点输入已占用 positionOS 这个名字)
VARYING float fogFactor;

#ifdef VERTEX
void ToonVert()
{
    VertexPositionInputs pos = GetVertexPositionInputs(positionOS.xyz);
    VertexNormalInputs nrm = GetVertexNormalInputs(normalOS, tangentOS);
    gl_Position = pos.positionCS;
    positionWS = pos.positionWS;
    objectPositionOS = positionOS.xyz;
    normalWS = nrm.normalWS;
    tangentWS = vec4(nrm.tangentWS, tangentOS.w * GetOddNegativeScale());
    uv = texcoord0.xy;
    fogFactor = ComputeFogFactor(pos.positionCS.z);
}
#endif
#endif

// 色阶:把连续的 0~1 明暗压成“亮 / 暗”两档,softness 控制交界的宽度(0 = 硬边)
float ToonBand(float x, float threshold, float softness)
{
    return smoothstep(threshold - softness, threshold + softness, x);
}

// 菲涅尔边缘光:越靠近轮廓(N·V → 0)越亮,再做一次色阶得到“一条线”
float FresnelRim(vec3 N, vec3 V, float width, float softness)
{
    float f = 1.0 - saturate(dot(N, V));
    return ToonBand(f, 1.0 - width, softness);
}

// 风格化高光(思路来自 Anjyo & Hiramitsu 2003):在切线空间里对半角向量 H 做平移、缩放、分裂,
// 最后用“高光区域的形状”切出硬边。T/B 是表面的切线与副切线。
float StylizedSpecular(vec3 N, vec3 T, vec3 B, vec3 H, vec4 shiftScale, vec2 split, float square, float threshold, float softness)
{
    H = normalize(H + shiftScale.x * T + shiftScale.y * B);                 // 平移:整体挪动高光
    H = normalize(H - shiftScale.z * dot(H, T) * T - shiftScale.w * dot(H, B) * B);   // 方向缩放:拉长/压扁
    H = normalize(H - split.x * sign(dot(H, T)) * T - split.y * sign(dot(H, B)) * B); // 分裂:一分为二/四
    // 方形化:H 在切平面上的分量 (x, y) 的长度就是 sin(N,H)。“N·H ≥ 阈值”等价于“长度 ≤ sin(阈值角)”。
    // 把长度换成 Lp 范数,p = 2 是圆,p 越大越接近正方形
    vec2 xy = abs(vec2(dot(H, T), dot(H, B)));
    float p = mix(2.0, 10.0, square);
    float d = pow(pow(xy.x, p) + pow(xy.y, p), 1.0 / p);
    float sinTh = sqrt(saturate(1.0 - threshold * threshold));
    return smoothstep(-softness, softness, sinTh - d) * step(0.0, dot(N, H));
}

// 发丝高光的核心(Kajiya-Kay):沿发丝方向 T 的“圆柱”高光,sin(T,H) 越接近 1 越亮
float StrandSpecular(vec3 T, vec3 N, vec3 H, float shift, float exponent)
{
    T = normalize(T + shift * N);              // 沿法线挪动切线 = 让高光带上下移动
    float ToH = dot(T, H);
    float sinTH = sqrt(saturate(1.0 - ToH * ToH));
    return smoothstep(-1.0, 0.0, ToH) * pow(sinTH, exponent);
}

// 描边外扩:在裁剪空间沿法线方向推开固定的像素宽度。
//   normalOS:用于外扩的法线(可以是平滑后的法线);widthPx:线宽(像素)
vec4 OutlinePositionCS(vec3 posOS, vec3 nrmOS, float widthPx)
{
    vec4 positionCS = TransformObjectToHClip(posOS);
    vec3 nWS = TransformObjectToWorldNormal(nrmOS);
    vec3 normalCS = mat3(unity_MatrixVP) * nWS;   // 方向向量只受 VP 的 3×3 部分影响(= TransformWorldToHClipDir)
    // 先把方向换到“像素空间”再归一化:NDC 的 x、y 一个单位对应的像素数不同(宽高比),
    // 直接 normalize(normalCS.xy) 会让横向和纵向的线宽不一致
    vec2 dirPx = normalize(normalCS.xy * _ScreenParams.xy + 1e-6);
    // NDC 的 [-1,1] 对应整个屏幕,所以 1 像素 = 2 / 屏幕尺寸;乘 w 是为了抵消之后的透视除法
    vec2 offset = dirPx * widthPx * 2.0 / _ScreenParams.xy;
    positionCS.xy += offset * positionCS.w;
    return positionCS;
}

// 对照组:物体空间外扩(线宽随距离变化,近粗远细)
vec4 OutlinePositionCS_ObjectSpace(vec3 posOS, vec3 nrmOS, float width)
{
    return TransformObjectToHClip(posOS + normalize(nrmOS) * width);
}

#endif