跳到正文
Qiita · Unity 开发社区· 来源· thymesoftware·· 43 分钟前AI 评分38

用 Unity URP 实现《葬送的芙莉莲》风格绘本风着色器

『葬送のフリーレン』エンディングテーマ絵本風シェーダーの作り方

正文 · AI 翻译

前言

本文将介绍在 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 显式指定微分值。

总结

我自己也认为要完全重现视频的外观仍然很困难,虽然这个着色器还在调整中,但如果能为正在制作彩色铅笔、绘本风格着色器的朋友提供参考,我将不胜感激。

pencil_runtime_screen_v6_15s_qiita.gif

许可证

imageLicenseLogo.png
© Unity Technologies Japan/UCL

0

查看点赞用户列表

0

注册为新用户以更便捷地使用 Qiita

  1. 获取符合您需求的文章
  2. 您可以高效地回顾有用信息
  3. 您可以使用深色主题

注册后您可以做什么

来源:Qiita · Unity 开发社区 · qiita.com