0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Unity URP Shader】崩壊:スターレイル風トゥーンシェーダー再現 学習記録3

Posted at

はじめに

最後に、アウトラインと前髪越しに眉毛が透けて見える効果を実現しました。

アウトラインの実現には、主にNormalを滑らかにして外側に膨張させる方法を用いました。

Outline

Preparation work

モデルの元々の法線をそのまま利用してメッシュを外側に膨張させると、エッジの尖った部分や急な角度の箇所でアウトラインが途切れてしまうなどの問題が生じます。
スクリーンショット 2025-06-26 182736.png
そのため、モデルの平滑化法線を算出し、計算で使用するために頂点カラーとして格納します。
Blenderで、計算したスムーズ法線を保存するための頂点カラーを新しく作成します。
スクリーンショット 2025-06-26 180839.png
さらに、新しい頂点グループを作成し、目などアウトラインが不要な部分を除去して、アウトラインが必要な部分だけを残します。
スクリーンショット 2025-06-26 181021.png
Blenderのスクリプティング(Scripting)に移り、スクリプトを使って**平滑法線(スムーズ法線)**を一括で計算し、頂点カラーに格納します。

計算時には、メッシュの名称とアウトラインが必要な頂点グループのインデックス(番号)を見つける必要があります。計算した法線はタンジェント空間(切線空間)へ変換する必要があります。そうすることで、キャラクターのボーンが変化しても、アウトラインがキャラクターと一緒に正しく追従して動くようになります。

from mathutils import *
from math import *
import numpy as np
import bpy

me = bpy.data.meshes["流萤3.0"]
me.calc_tangents(uvmap = "UVMap")
dict = {}


def vec2str(vec):
    return "x=" + str(vec.x) + ",y=" + str(vec.y) + ",z=" + str(vec.z)


def cross_product(v1, v2):
    return Vector((v1.y * v2.z - v2.y * v1.z, v1.z * v2.x - v2.z * v1.x, v1.x * v2.y - v2.x * v1.y))


def vector_length(v):
    return sqrt(v.x * v.x + v.y * v.y + v.z * v.z)


def dot_product(v1, v2):
    return v1.x * v2.x + v1.y * v2.y + v1.z * v2.z


def included_angle(v1, v2):
    return np.arccos(dot_product(v1, v2) / (vector_length(v1) * vector_length(v2)))


def normalize(v):
    return v / vector_length(v)


def need_outline(vertex):
    need = False
    for g in vertex.groups:
        if g.group == 752:
            need = True
            break
    return need


for v in me.vertices:
    co = v.co
    co_str = vec2str(co)
    dict[co_str] = []

print("==============")

for poly in me.polygons:
    l0 = me.loops[poly.loop_start]
    l1 = me.loops[poly.loop_start + 1]
    l2 = me.loops[poly.loop_start + 2]

    v0 = me.vertices[l0.vertex_index]
    v1 = me.vertices[l1.vertex_index]
    v2 = me.vertices[l2.vertex_index]

    n = cross_product(v1.co - v0.co, v2.co - v0.co)
    if vector_length(n) == 0:
        continue
    n = normalize(n)

    co0_str = vec2str(v0.co)
    co1_str = vec2str(v1.co)
    co2_str = vec2str(v2.co)

    if co0_str in dict and need_outline(v0):
        w = included_angle(v2.co - v0.co, v1.co - v0.co)
        dict[co0_str].append({"n": n, "w": w, "l": l0})
    if co1_str in dict and need_outline(v1):
        w = included_angle(v0.co - v1.co, v2.co - v1.co)
        dict[co1_str].append({"n": n, "w": w, "l": l1})
    if co2_str in dict and need_outline(v2):
        w = included_angle(v1.co - v2.co, v0.co - v2.co)
        dict[co2_str].append({"n": n, "w": w, "l": l2})

for poly in me.polygons:
    for loop_index in range(poly.loop_start, poly.loop_start + poly.loop_total):
        vertex_index = me.loops[loop_index].vertex_index
        v = me.vertices[vertex_index]
        smoothnormal = Vector((0, 0, 0))
        weightsum = 0
        if need_outline(v):
            costr = vec2str(v.co)
            if costr in dict:
                a = dict[costr]
                for d in a:
                    n = d["n"]
                    w = d["w"]
                    smoothnormal += n * w
                    weightsum += w
            if smoothnormal != Vector((0, 0, 0)):
                smoothnormal /= weightsum
                smoothnormal = normalize(smoothnormal)

            normal = me.loops[loop_index].normal
            tangent = me.loops[loop_index].tangent
            bitangent = me.loops[loop_index].bitangent

            normalTSX = dot_product(tangent, smoothnormal)
            normalTSY = dot_product(bitangent, smoothnormal)
            normalTSZ = dot_product(normal, smoothnormal)

            normalTS = Vector((normalTSX, normalTSY, normalTSZ))

            color = [normalTS.x * 0.5 + 0.5, normalTS.y * 0.5 + 0.5, normalTS.z * 0.5 + 0.5, 1]
            me.vertex_colors.active.data[loop_index].color = color

平滑法線(スムーズ法線)を計算し終わった後も、やはりアウトラインに問題が残りました。描線を入れたくない部分にも**奇妙な線が現れてしまい、具体的な原因は私も分かりません。
スクリーンショット 2025-06-26 182630.png
そして、頂点カラーのAチャンネル(アルファチャンネル)を利用して、マスクを自作し、変なアウトラインが出ている箇所を覆い隠しました(マスクしました)。

Blender 2.93バージョンでは、Aチャンネルへの直接ペイントがサポートされていないため、新しい頂点カラーレイヤーを作成してペイントし、ペイント後にスクリプトを使ってそのデータを頂点カラーのAチャンネルにインポートする必要がありました。

import bpy
import bmesh

# 进入编辑模式
if bpy.context.object.mode != 'EDIT':
    bpy.ops.object.mode_set(mode='EDIT')

obj = bpy.context.edit_object
mesh = obj.data
bm = bmesh.from_edit_mesh(mesh)

# 获取颜色图层(适用于 Blender 2.93)
main_layer = bm.loops.layers.color.get("SmoothNormal")     # 主图层,包含RGB法线
mask_layer = bm.loops.layers.color.get("Mask")    # Mask图层,R通道用于描边控制

if not main_layer or not mask_layer:
    raise Exception("找不到名为 'Col' 或 'Mask' 的顶点色图层,请确认图层名称正确")

# 执行颜色拷贝:将 Mask 的 R 通道写入主图层的 A 通道(Blender 2.93 只支持 RGB,A 也存储在 color 中)
for face in bm.faces:
    for loop in face.loops:
        col_main = loop[main_layer]
        col_mask = loop[mask_layer]
        if col_mask[0] > 0:
            col_mask[0] = 1
        loop[main_layer] = (col_main[0], col_main[1], col_main[2], col_mask[0])  # R -> A

bmesh.update_edit_mesh(mesh)
print("已成功将 Mask 图层的 R 通道写入 Col 图层的 Alpha 通道")

Outline Shader

データを準備したら、いよいよアウトライン Pass を記述できます。

まずはPassのモードを設定します。

Name "Outline"
Blend One Zero
Cull Front
ZTest Off
ZTest LEqual
Tags{"LightMode" = "outline"}

頂点カラーに格納された法線はタンジェント空間にあるため、TBN行列を構築し、法線をタンジェント空間からワールド空間に変換してから、頂点の外拡を行います。

float3 normalWS = TransformObjectToWorldNormal(v.normal);
float3 tangentWS = TransformObjectToWorldDir(v.tangent.xyz);
float3 bitangentWS = cross(normalWS, tangentWS) * v.tangent.w;
float3x3 TBN = float3x3(tangentWS, bitangentWS, normalWS);

アウトラインの視覚的な効果をより良くするために、カメラとキャラクターとの距離に応じて、描線の太さを適切に調整する必要があります。

// 计算自适应缩放
float3 viewPos = TransformWorldToView(posWS);
float viewDistance = -viewPos.z; //View空间中z为负

// 基于透视投影的缩放因子
float scale = viewDistance * unity_CameraProjection[1][1]; //使用投影矩阵的[1][1]元素
float adaptiveWidth = _OutlineWidth * scale * _AdaptiveScale;
//限制最小和最大宽度
adaptiveWidth = clamp(adaptiveWidth, _MinOutlineWidth, _MaxOutlineWidth);

最後に頂点カラー情報をデコードし、頂点膨張を行います。

float3 tangentSpaceNormal = v.color.rgb * 2.0 - 1.0;
float3 SmoothNormalWS = normalize(mul(tangentSpaceNormal, TBN));

#ifdef OUTLINE_IS_ON
    posWS += SmoothNormalWS * 0.001 * adaptiveWidth * v.color.a;
#endif

o.pos = TransformWorldToHClip(posWS);

以上でアウトラインPass用の頂点シェーダーが完成です。

v2f vert (a2v v)
            {
                v2f o;
                o.uv0 = v.texcoord0;

                //构建TBN矩阵
                float3 normalWS = TransformObjectToWorldNormal(v.normal);
                float3 tangentWS = TransformObjectToWorldDir(v.tangent.xyz);
                float3 bitangentWS = cross(normalWS, tangentWS) * v.tangent.w;
                float3x3 TBN = float3x3(tangentWS, bitangentWS, normalWS);

                float3 posWS = TransformObjectToWorld(v.vertex.xyz);

                // 计算自适应缩放
                float3 viewPos = TransformWorldToView(posWS);
                float viewDistance = -viewPos.z; //View空间中z为负

                // 基于透视投影的缩放因子
                float scale = viewDistance * unity_CameraProjection[1][1]; //使用投影矩阵的[1][1]元素
                float adaptiveWidth = _OutlineWidth * scale * _AdaptiveScale;

                //限制最小和最大宽度
                adaptiveWidth = clamp(adaptiveWidth, _MinOutlineWidth, _MaxOutlineWidth);

                //解码顶点色信息
                float3 tangentSpaceNormal = v.color.rgb * 2.0 - 1.0;
                float3 SmoothNormalWS = normalize(mul(tangentSpaceNormal, TBN));

                #ifdef OUTLINE_IS_ON
                //世界空间下,严平滑法线方向挤出
                posWS += SmoothNormalWS * 0.001 * adaptiveWidth * v.color.a;
                #endif

                o.pos = TransformWorldToHClip(posWS);

                return o;
            }

フラグメントシェーダーでは、描線の色を直接返します。
スクリーンショット 2025-06-26 185454.png

前髪越しに眉毛を透かす

眉毛と目を前髪越しに透けさせ、半透明のような効果を出すためには、ステンシルテスト機能を使用する必要があります。

ステンシルテストは、GPUレンダリングパイプラインのピクセルごとの処理(逐次フラグメント操作)ステージに位置します。フラグメントシェーダーが完了した後、ステンシルテストに入り、ステンシルテストに合格したフラグメントだけが、さらにデプステスト(深度テスト)に進みます。

まず、レンダリングキュー(Render Queue)を設定し、描画順序を「眉毛 → その他の部分 → 髪の毛(劉海)」となるように確保する必要があります。

//眉毛
Tags { "RenderType"="Queue"
            "Queue"="Geometry-1"
        }
//其他部分
Tags { "RenderType"="Queue"
            "Queue" = "Geometry"
 }
//头发
Tags { "RenderType"="Queue"
            "Queue" = "Geometry+1"
        }

次に、ステンシルテストのパラメータ設定を行います。

考え方としては、まず眉毛の部分で参照値(Ref/Reference Value)を2に設定し、参照値以上の値であればテストを通過させ、さらに現在の参照値(2)でバッファの内容を置き換える(Replace)ようにします。

Stencil
            {
                Ref 2
                Comp GEqual
                Pass Replace     
            }

次に、その他の部分には、参照値を2より大きい値、例えば3に設定し、ステンシルテストを通過できるようにします。

そして、テスト通過後にはバッファに0を書き込むように設定します。

Stencil
            {
                Ref 3
                Comp GEqual
                Pass Zero    
            }

最後に、髪の毛(前髪)の部分を設定します。参照値を2より小さい値、例えば1に設定します。

参照値(1)が バッファの値以上の時にステンシルテストを通過し、バッファの内容は変更しない(Keep)ようにします。

Stencil
            {
                Ref 1
                Comp GEqual
                Pass Keep
            }

この設定により、眉毛が髪の毛(前髪)の上に重なって見えるようになります。
スクリーンショット 2025-06-26 191408.png
半透明のブレンド効果を達成するため、Blend SrcAlpha OneMinusSrcAlphaを使用します。

これは、2つの値のアルファ値(A値)に基づいてブレンドを行うものです。そこで、ブレンドの程度を制御するための_Alphaパラメータを追加します。

最終的な効果は以下のようになります。
スクリーンショット 2025-06-26 191705.png

ポストプロセス(Post-Process)

直接Volumeを使用しました。参考にした記事はこちらです:https://zhuanlan.zhihu.com/p/699682089

色を調整したところ、だいたいこのような感じになり、まあまあだと思います。

ただ、アウトラインにギザギザ感(鋸歯/エイリアス)が強く出ています。また、髪の毛の部分ですが、ゲーム内の法線が改変されているようで、ライティングの際、頭全体の光の当たり方が球体のように見えます。これはさらに修正の余地がありそうです。
参考

卡通渲染全流程,以崩坏星穹铁道角色为例 | Portfolio & Blog - LiKira

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?