用 Unity URP 实现《葬送的芙莉莲》风格绘本风着色器
『葬送のフリーレン』エンディングテーマ絵本風シェーダーの作り方
前言
本文将介绍在 Unity 的 URP(通用渲染管线)中实现一种角色着色器,旨在呈现出仿佛彩色铅笔绘制的绘本般生动活动的视觉效果。
契机是观看了《葬送的芙莉莲》第二季的非演职员表片尾,中途心想“这明明是 3DCG 啊”,觉得效果非常出色,于是尝试使用 Unity 自行还原。
代码仅展示了为说明思路所必需的部分,并进行了简化。希望能作为您构思的参考。文中未提供系数和参数的具体数值。此外,背景的处理采用了其他机制,不在本文讨论范围内。
运行环境为 Unity 6000.6.5f1、URP 17.6,色彩空间为线性(Linear)。
范例特征与实现方针
| 手本的が特徴 | シェーダーの方針 |
|---|---|
| 絵は1秒に12枚。2コマ打ちのアニメーション | 時間を12fpsで刻み、1枚ごとに乱数を変える |
| 1枚ごとに絵全体が上下左右に跳ぶ | クリップ座標をランダムにずらす |
| 看起来有动态的线条,并非纸张的白色,而是原始颜色的明暗变化 | 线条颜色由纹理颜色生成 |
| 白色保持白色,深色则加深同色调 | 通过幂运算生成颜色 |
| 阴影不是灰色,而是蓝紫色铅笔叠加的颜色 | 阴影不使用黑色,而是用冷色进行乘法混合 |
基本部件
哈希
着色器中没有标准的随机数函数,因此我们根据坐标生成伪随机数。这里使用的是不使用 sin 的哈希(Dave Hoskins 的 Hash without Sine),并将其转换到 -1〜1 的范围后使用。
float2 Hash22(float2 p)
{
float3 p3 = frac(p.xyx * float3(0.1031, 0.1030, 0.0973));
p3 += dot(p3, p3.yzx + 33.33);
return frac((p3.xx + p3.yz) * p3.zy) * 2.0 - 1.0;
}
渐变噪点
Value Noise 容易残留网格形状,会出现像漫画网点纸那样的方格。因此,改用让格点带有梯度的 Gradient Noise。插值使用 5 次 fade 函数,使格点处直到二阶导数都保持连续。
float Noise(float2 p)
{
float2 i = floor(p);
float2 f = frac(p);
float2 u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0);
float a = dot(Hash22(i), f);
float b = dot(Hash22(i + float2(1, 0)), f - float2(1, 0));
float c = dot(Hash22(i + float2(0, 1)), f - float2(0, 1));
float d = dot(Hash22(i + float2(1, 1)), f - float2(1, 1));
return lerp(lerp(a, b, u.x), lerp(c, d, u.x), u.y);
}
回転
要改变线的方向,需要对坐标进行二维旋转。sincos 是 HLSL 的内置函数,可同时求出 sin 和 cos。
float2 Rotate(float2 p, float angle)
{
float s, c;
sincos(angle, s, c);
return float2(c * p.x - s * p.y, s * p.x + c * p.y);
}
不依赖分辨率的坐标
线条粗细等尺寸,全部以“高度为 1080 的画面中的 px”来指定。只要将屏幕 UV 转换到这个坐标,即使分辨率改变,画面的外观也不会改变。
float2 ReferencePixels(float4 screenPos)
{
float2 uv = screenPos.xy / max(screenPos.w, 1e-5); // 透視除算
return uv * float2(_ScreenParams.x / _ScreenParams.y, 1.0) * 1080.0;
}
写作
不将光照结果直接乘以颜色,而是汇总为shade(0 为亮,1 为暗)这一数值。该数值用于线条的笔压和阴影的铅笔用量。
Light light = GetMainLight(TransformWorldToShadowCoord(positionWS));
float3 viewDirWS = GetWorldSpaceNormalizeViewDir(positionWS);
// ラップライティング。_LightWrap = 1 のとき Half Lambert と同じ形になる
float ndotl = saturate((dot(normalWS, light.direction) + _LightWrap) / (1.0 + _LightWrap));
float lightValue = saturate(_Ambient + ndotl * light.shadowAttenuation * _LightIntensity);
// フレネル項に近い形で、輪郭付近を少し暗くする
float silhouette = pow(1.0 - saturate(dot(normalWS, viewDirWS)), _SilhouettePower);
float shade = saturate(1.0 - lightValue + silhouette * _SilhouetteShade);
在绘制内侧的材质中,如果不反转法线,内侧就无法被正确照亮。
half4 Fragment(Varyings input, FRONT_FACE_TYPE frontFace : FRONT_FACE_SEMANTIC) : SV_Target
{
float3 normalWS = normalize(input.normalWS) * IS_FRONT_VFACE(frontFace, 1.0, -1.0);
// ...
}
線
线条的基本原理,是拉伸后的噪声。先把坐标旋转,使线条方向与 x 轴对齐,然后叠加在 x 和 y 方向上尺度差异极大的噪声。
// 簡略化したもの
float StrokeLayer(float2 px, float angle)
{
float2 r = Rotate(px, -angle); // 線の向きを x 軸にそろえる
float wide = Noise(float2(r.x / (len * a), r.y / (width * b)));
float main = Noise(float2(r.x / len, r.y / width));
float fine = Noise(float2(r.x / (len * c), r.y / (width * d)));
return saturate(0.5 + wide * w0 + main * w1 + fine * w2);
}
len = width * _StrokeLength,因此,_StrokeLength 是线条的纵横比。
使用 smoothstep 对该噪声进行阈值处理,以确定深色线条的数量。笔压越高,阈值越低,深色线条就越多。
float pressure = lerp(_LightPressure, _ShadowPressure, shade);
float threshold = 1.0 - saturate(pressure);
float coverage = smoothstep(threshold - _StrokeSoftness, threshold + _StrokeSoftness, stroke + grain * k);
如果只绘制浓重部分而留空其余部分,宽平面上的缝隙会排成一列,看起来像雨。因此我们不制造缝隙,而是用深色和浅色两种颜色涂满整个画面。
纸张纹理
铅笔芯只能落在纸张的凸起部分,因此线条会分裂成细小的颗粒。我们使用略微沿线条方向拉伸的噪声来实现这一点。如果用另一个噪声来偏移噪声的输入坐标(即域变形),颗粒的边缘就不会变得圆润,而是呈现锯齿状。
// 簡略化したもの
float PaperTooth(float2 px, float angle)
{
float2 r = Rotate(px, -angle);
float2 q = float2(r.x / (size * stretch), r.y / size);
float2 warp = Noise2(q * a + seed0) * b; // ドメインワープ
float n = Noise(q + warp) + Noise(q * c + warp + seed1) * d;
return smoothstep(lo, hi, 0.5 + n); // 1:凸部、0:谷
}
Noise2 是一个将输入偏移并调用 Noise 两次、返回二维值的函数。结果接近 0 的区域将被替换为纸张颜色。
color = lerp(color, _PaperColor.rgb, saturate(paper));
色
べき乗で2本の色鉛筆を作る
手本の線は、紙の白が透けたものではなく、元の色の濃い版と淡い版が重なったものでした。そこで、1つの色から濃淡の2色を作ります。
// 簡略化したもの
float3 StrokeDeep(float3 color)
{
float3 p = max(color, 1e-4); // pow(0, x) を避ける
return pow(p, DeepPower(PencilLuminance(p))); // 指数は 1 より大きい
}
float3 StrokeLight(float3 color)
{
float3 p = max(color, 1e-4);
return pow(p, LightPower(PencilLuminance(p))); // 指数は 1 より小さい
}
float3 result = lerp(StrokeLight(color), StrokeDeep(color), coverage);
べき乗には次の性質があります。
- 白は白のまま残る。 1 は何乗しても 1 なので、ハイライトが濁りません。
- 暗くすると色が濃くなる。 RGB の比が広がるので、灰色に沈みません。たとえば (0.8, 0.4) を2乗すると (0.64, 0.16) になり、比は 2 倍から 4 倍になります。
指数を固定すると、暗いところが「真っ黒の上に明るい筋」になるので、輝度に応じて指数を変えています。
亮度通过 Rec.709 的系数计算得出。
float PencilLuminance(float3 color)
{
return dot(color, float3(0.2126, 0.7152, 0.0722));
}
颜色计算是在读取纹理后的线性颜色状态下进行的。即使指数相同,与乘以 sRGB 值的情况相比,其效果也会不同。
彩度
越暗的地方饱和度越高。通过对亮度的灰色和原始颜色进行线性插值,并使用超过 1 的系数进行外推,可以提高饱和度。
float3 PencilSaturate(float3 color, float amount)
{
return saturate(lerp(PencilLuminance(color).xxx, color, amount));
}
float3 pigment = PencilSaturate(baseColor, 1.0 + shade * _ShadowSaturation);
影
阴影不是通过变暗来实现的,而是通过叠加青紫色的铅笔图案(乘法混合)。第二层线条使用与第一层不同的种子生成并应用。
// 簡略化したもの
float3 shadowPencil = lerp(float3(1, 1, 1), _ShadowTint.rgb, _ShadowTintStrength);
color = lerp(color, color * shadowPencil, shadowDeposit);
shadowDeposit 是对 ⟨shade⟩ 接收到的阴影强度(1.0 - light.shadowAttenuation)进行阈值处理后的值。
輪郭線
在 ⟨Cull Front⟩ 通道中,采用背面法将顶点沿法线方向膨胀绘制。线条粗细通过噪声和阴影进行变化。
// 頂点シェーダー(簡略化したもの)
float3 p = input.positionOS.xyz * scale;
float2 seed = Hash22(float2(frame, s)) * k * _BoilContour;
float pressure = saturate(0.5 + Noise(p.xy + p.z * q + seed));
// 光が当たる側を細くする
float lightThinning = lerp(1.0 - _ContourLitThinning, 1.0, shade);
float width = _OutlineWidth * lerp(thin, thick, pressure) * lightThinning;
float3 expanded = input.positionOS.xyz + normalize(input.normalOS) * width;
output.positionCS = BoilClipPosition(TransformObjectToHClip(expanded));
在片段着色器中,将噪声应用于 clip 以产生线条的断续效果。线条颜色并非黑色,而是通过降低纹理亮度并混合少量 _ShadowTint 来实现。
float lift = saturate(0.5 + Noise((px + boil) / scale) * k);
clip(lift - _ContourBreakup);
运动:每秒12帧,重绘
使用 floor 对时间进行量化时,该值每秒仅变化12次。
float frame = floor(_Time.y * _BoilFPS); // 12 なら1秒12枚
随后将 frame 输入哈希函数,逐张改变下一项。
- 线条的位置与微小角度
- 纸张纹理
- 轮廓
- 颜色与线条的偏移
偏移轮廓
以屏幕上的像素单位偏移裁剪坐标。乘以w是为了在透视除法之后移动指定的距离。
float4 BoilClipPosition(float4 positionCS)
{
float2 toPx = 0.5 * 1080.0 * float2(aspect, 1.0);
float2 px = positionCS.xy / positionCS.w * toPx;
float2 offsetPx = BoilShift(frame) + BoilField(px, frame) * _BoilSilhouette;
positionCS.xy += offsetPx / toPx * positionCS.w;
return positionCS;
}
BoilShift 是整体偏移,BoilField 是根据位置变化的平滑偏移。
此处理应包含所有写入颜色和深度的通道。如果只偏移部分通道,深度将不匹配。
纸在画面上,线在角色上
如果单纯用屏幕坐标来绘制线条,当角色移动时,线条看起来就像是在图案上滑动。因此,我们另外建立以对象原点为基准的坐标系。
float4 anchorPos = ComputeScreenPos(TransformObjectToHClip(float3(0, 0, 0))); // 頂点シェーダー
float2 paperPx = ReferencePixels(screenPos); // 紙の目:画面に固定
float2 drawPx = paperPx - ReferencePixels(anchorPos); // 線:キャラクターに追従
纸张的纹理保持位置基准在屏幕上,仅每帧改变随机偏移量。
float2 toothPx = paperPx + Hash22(float2(frame, s)) * size * _BoilPaper; // _BoilPaper = 0 なら同じ紙のまま
让颜色溢出
手绘上色通常与线条不完全吻合,因此读取纹理的UV坐标也每帧进行偏移。由于UV密度因模型部位而异,我们使用屏幕微分将“屏幕像素”换算为UV量。
float2 uvDdx = ddx(uv);
float2 uvDdy = ddy(uv);
float2 uvPerPxX = uvDdx / ddx(paperPx.x);
float2 uvPerPxY = uvDdy / ddy(paperPx.y);
float2 colorUV = uv + uvPerPxX * offset.x + uvPerPxY * offset.y;
// ずらす前の UV の微分を渡して、ミップレベルの飛びを防ぐ
float3 baseColor = SAMPLE_TEXTURE2D_GRAD(_BaseMap, sampler_BaseMap, colorUV, uvDdx, uvDdy).rgb;
在偏移量急剧变化的地方,colorUV 的微分值会变大。如果按常规方式采样,只有这些地方会被选中较低的 mip 层级而变得模糊,因此通过 SAMPLE_TEXTURE2D_GRAD 显式指定微分值。
总结
我自己也认为要完全重现视频的外观仍然很困难,虽然这个着色器还在调整中,但如果能为正在制作彩色铅笔、绘本风格着色器的朋友提供参考,我将不胜感激。
许可证

© Unity Technologies Japan/UCL
查看点赞用户列表
0
注册为新用户以更便捷地使用 Qiita
- 获取符合您需求的文章
- 您可以高效地回顾有用信息
- 您可以使用深色主题
来源:Qiita · Unity 开发社区 · qiita.com
