返回作品索引WORK / CASE STUDY
Shader

碧蓝幻想角色渲染

头 / 身 / 武器分离的角色卡渲:多套配色贴图、A 通道区分皮肤 Mask、顶点色驱动分区控制,附完整 Shader 代码。

独立作品

让周围轻一点,文字近一点。
碧蓝幻想角色最终渲染:青色星空背景中的粉发持刀角色
作品主视觉点击画面,查看细节
ENGINE
Unity · URP
TECH
NPR · Shader · HLSL
ROLE
Shader
YEAR
2026
案例目录40 小节

模型

头部,身体,武器分离,动画分离。

贴图

基础(默认)的一套贴图加各类不同的配色贴图

NRM_base基本颜色贴图

贴图

RGB通道

颜色信息

A通道

区分皮肤和非皮肤区域的Mask

预览

RGB通道
A通道

NRM_Sss贴图

贴图

RGB通道

含颜色信息,相较于NRM_base贴图,亮度降低,为暗部颜色

A通道

可以用于控制边缘光强度

预览

RGB通道

含颜色信息,相较于NRM_base贴图,亮度降低,为暗部颜色

A通道

NRM_ilm贴图

贴图

RGBA
R通道

高光强度信息

G通道

用于偏移阴影

B通道

高光范围大小,类光滑度

A通道

内描边信息 这里的描线是作为主体,较为严格,要求横平竖直

预览

R通道

高光强度信息

G通道

用于偏移阴影,因为偏移阴影有正负之分,所以选择中灰度为不偏移,小于0.5和大于0.5都会偏移

B通道

高光范围大小,类光滑度

A通道

内描边信息 这里的描线是作为主体,较为严格,要求横平竖直

NRM_detail贴图

贴图

含有部部分细节描边信息以及面部的渐变色 这里的描线是作为点缀使用,较为随意,没有要求横平竖直,但是为了避免锯齿所以像素要求更大,为2048x2048 注意:此贴图采用第二套UV进行采样

预览

顶点色

R通道

存入了AO信息,控制遮挡关系

G通道

空

B通道

此通道是利用顶点色控制描边的深度

A通道

空

Shader实现

获得基本Diffuse颜色

采用NdotV获得光照信息,获得值范围为-1~1

//向量
half3 normalDir = normalize(i.normal_world);
half3 lightDir = normalize(_WorldSpaceLightPos0.xyz);            
//diffuse
half NdotL = dot(normalDir, lightDir);//-1~1结果

卡通渲染二值化

简易实现二值化方法

此方法控制性较低 阴影二值化可以使用Step函数进行实现

half toon_diffuse = step(0.0, NdotL);//色阶化(阴影二值化)

替换方法

_ToonThesHold表示阴影偏移阈值,控制阴影偏移 ToonHardness表示阴影软硬度,控制阴影软硬

_ToonThesHold("ToonThesHold", Range(0, 1)) = 0.5
_ToonHardness("ToonHardness", Float) = 20.0

将原来的NdotV替换为半兰伯特half_lambert,实际就是重映射到了0~1,取值范围仅原有的一半 使用half_lambert减去_ToonThesHold即可控制阴影偏移,乘上_ToonHardness并进行钳制到0~1,增大_ToonHardness时小数则会越来越少即可达到边缘变硬

//diffuse
half NdotL = dot(normalDir, lightDir);          //-1~1结果
half half_lambert = (NdotL + 1.0) * 0.5;        //0~1结果
half toon_diffuse = saturate((half_lambert - _ToonThesHold) * _ToonHardness); 

_ToonThesHold = 0.5; _ToonHardness = 20;时

_ToonThesHold = 0.7; _ToonHardness = 20;时阴影偏移

_ToonThesHold = 0.7; _ToonHardness = 40;时阴影边缘变硬

获得基本卡通光影

混合阴影输出颜色

将基本颜色贴图颜色和光影颜色混合

// sample the texture
half4 base_map = tex2D(_BaseMap, uv1);
half3 base_color = base_map.rgb;
//diffuse
half NdotL = dot(normalDir, lightDir);          //-1~1结果
half half_lambert = (NdotL + 1.0) * 0.5;        //0~1结果
//色阶化(阴影二值化)
half toon_diffuse = saturate((half_lambert - _ToonThesHold) * _ToonHardness);
******************************************************************
//最终颜色
half3 final_color = toon_diffuse * base_color;
return float4(final_color, 1.0);

此时获得了基本的卡通光照,但阴影部分还是死黑的,所以需要后续处理

简单的阴影提亮方式

此方法精细度稍差,所以碧蓝幻想采用了SSS贴图进行采样黑色部分 将Toon_diffuse直接加上0.5,这样原本死黑的部分变为中灰度0.5,会显示原本颜色的0.5亮度

half toon_diffuse = saturate((half_lambert - _ToonThesHold) * _ToonHardness);   
Toon_diffuse = saturate(toon_diffuse + 0.5);
******************************************************************
//最终颜色
half3 final_color = toon_diffuse * base_color;
return float4(final_color, 1.0);

碧蓝幻想暗部颜色采样

采样了NRM_Sss贴图的颜色信息,使用toon_diffuse作为lerp函数的Alpha值控制输出亮部和暗部的颜色

// sample the texture
half4 base_map = tex2D(_BaseMap, uv1);
half3 base_color = base_map.rgb;
half4 sss_map = tex2D(_SSSMap, uv1);
half3 sss_color = sss_map.rgb;
//diffuse
half NdotL = dot(normalDir, lightDir);          //-1~1结果
half half_lambert = (NdotL + 1.0) * 0.5;        //0~1结果
half toon_diffuse = saturate((half_lambert - _ToonThesHold) * _ToonHardness);   
*****************************************************************************
half3 final_color = lerp(sss_color, base_color, toon_diffuse);
return float4(final_color, 1.0);

可以看出阴影部分颜色要精细很多

使用NRM_ilm贴图(G通道)

控制阴影偏移(G通道)

*使用half_lambert+diffuse_control对原阴影进行偏移

//ILM贴图
half4 ilm_map = tex2D(_ILMMap, uv1);
half spec_intensity = ilm_map.r;  //高光强度
half diffuse_control = ilm_map.g; //阴影偏移控制
half spec_size = ilm_map.b;       //高光大小
half inner_line = ilm_map.a;      //内描线
********************************************************************
half half_lambert_term = half_lambert + diffuse_control;
half toon_diffuse = saturate((half_lambert_term - _ToonThesHold) * _ToonHardness);

此时结果可见不正常,阴影不见了,原因是half_lambert取值范围是01,diffuse_control虽然是01的取值范围,但偏移值是按中灰度计算的,所以需要重映射到-1~1

重映射

half diffuse_control = ilm_map.g * 2.0 - 1.0; //阴影偏移控制

此时阴影恢复,头发有了阴影结构,如果阴影依旧不对则需要调整贴图,查看是否为127的中灰度值,应该是偏移数值有问题

添加AO

获取顶点色

//appdata
float4 color: Color;
//v2f
float4 vertex_color : TEXCOORD3;
//v2f vert (appdata v)
o.vertex_color = v.color;

取得顶点色R通道(AO信息)

  float ao = i.vertex_color.r; //AO

混合AO

 half half_lambert_term = half_lambert * ao + diffuse_control;

此时阴影细节更加丰富,已经达到初步效果

高光计算

一般高光计算

传统方式使用Bulinphong进行计算高光

特殊高光算法

使用NdotV获得菲涅尔遮罩,并映射到0~1范围

//spec
float NdotV = (dot(normalDir, viewDir) + 1.0) * 0.5;

NdotV结果如下:

因为有AO和阴影偏移影响所以需要混合AO信息和加上偏移

float spec_term = NdotV * ao + diffuse_control;

spec_term结果如下: 此时的阴影信息仅会收到视角影响,而正常情况下光照方向也会影响阴影位置,所以需要对正常光照下的阴影进行混合

高光项取9:1的比例混合 half_lambert 和 spec_term ps:此处使用偏移后的half_lambert_term更为合理,但两者结果一样可以不用修改

half half_lambert_term = half_lambert * ao + diffuse_control;

spec_term = half_lambert * 0.9 + spec_term * 0.1;
********************************************************************
spec_term = half_lambert_term * 0.9 + spec_term * 0.1;

half_lambert结果如下:

混合后spec_term结果如下:

spec_term - (1 - spec_size * _SpecSize)可以获得高光mask,spec_term减去高光区域(黑色)的即减去一个负数,该区域会变亮,减去白色区域则会变黑,最后求出高光区域,此处500是控制边缘软硬度

half toon_spec = saturate((spec_term - (1 - spec_size * _SpecSize)) * 500);

NRM_ilm贴图(B通道)spec_size

1 - spec_size * _SpecSize

toon_spec

设置将要混合的高光颜色

half3 spec_color  = (_SpecColor.xyz + base_color) * 0.5;

此时高光颜色为红色

将高光区域mask混合高光颜色和控制强度

half3 final_spec = toon_spec * spec_color * spec_intensity; 

最终高光结果

贴图描线添加

读取NRM_ilm贴图(A通道)和NRM_detail贴图,即内描线和细节描线

//ILM贴图
half4 ilm_map = tex2D(_ILMMap, uv1);
half spec_intensity = ilm_map.r;  //高光强度
half diffuse_control = ilm_map.g * 2.0 - 1.0; //阴影偏移控制
half spec_size = ilm_map.b;       //高光大小
half inner_line = ilm_map.a;      //内描线
//Detail贴图
half4 detail_map = tex2D(_DetialMap, uv2);//细节描线
*******************************************************
//描线
half3 inner_line_color = lerp(base_color * 0.2, float3(1,1,1), inner_line);

inner_line,内描线如下:

使用lerp函数,白色输出依旧为白色(1,1,1),而黑色部分输出原本颜色的0.2强度,即可获得混合原本颜色的描线 inner_line_color如下:

同理对细节描写进行颜色混合

half3 detail_line_color = lerp(base_color * 0.5, float3(1,1,1), detail_map.xyz);

*detail_map.xyz如下:

*detail_line_color如下:

将两种描线混合,因为两种线并不会有重复,所以乘法混合即可

half3 final_line = inner_line_color * detail_line_color;

final_line如下:

最终颜色混合

half3 final_color = (final_diffuse + final_spec) * final_line;

final_color如下:

碧蓝幻想在末尾做了一个类色调映射的计算,来压低亮度提高对比度

final_color = sqrt(max(exp2(log2(max(final_color,0)) * 2.2), 0.0)); //色彩调整
return float4(final_color, 1.0);

函数图如下:


效果预览

外轮廓线描边添加

复制Pass,删除多余代码,添加正面剔除,去除光照头文件

Cull Front
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"

修改顶点Shader,进行顶点偏移设置 获取顶点位置,加上顶点法线方向的单位向量x位于长度,即可按法线放大原来模型顶点,开启正面剔除后就可以获得轮廓描线。这俩乘上一个0.0001是控制_OutlineWidth的强度,使其更可控

v2f vert (appdata v)
{
	v2f o;
	//******************世界空间处理***********
	// float3 pos_world = mul(unity_ObjectToWorld, v.vertex).xyz;
	// float3 normal_world = normalize(UnityObjectToWorldNormal(v.normal));
	// pos_world += normal_world * _Outline; 
	// o.pos = mul(UNITY_MATRIX_VP, float4(pos_world, 1.0));
	
	//*******************观察空间处理**************
	float3 pos_view = UnityObjectToViewPos(v.vertex);
	float3 normal_world = UnityObjectToWorldNormal(v.normal);
	float3 outlinDir = normalize(mul((float3x3)UNITY_MATRIX_V,normal_world));
	pos_view += outlinDir * _OutlineWidth * 0.0001; 
	o.pos = mul(UNITY_MATRIX_P, float4(pos_view, 1.
	o.uv = v.texcoord0;
	o.vertex_color = v.color;
	return o;
}

修改片元Shader,为描线混合颜色

half4 frag (v2f i) : SV_Target
{
      
    // *****sample the texture******
    //Base贴图
    half4 base_map = tex2D(_BaseMap, i.uv);
    half3 base_color = base_map.rgb;

//此段混合颜色算法
    half maxComponent = max(max(base_color.r, base_color.g), base_color.b) - 0.004;
    half3 saturatedColor = step(maxComponent.rrr, base_color) * base_color;
    saturatedColor = lerp(base_color.rgb, saturatedColor, 0.6);
    half3 outlineColor = 0.8 * saturatedColor * base_color * _OutlineColor.xyz;

    return float4(outlineColor, 1.0);
}

此时轮廓描线如下:

此时描线均为同样粗细和深度,所以需要处理

使用顶点色,使用A通道进行绘制描边粗细的权重

pos_view += outlinDir * _OutlineWidth * 0.0001 * v.color.a; 

混合顶点色B通道,对描边进行Z轴深度控制

_OutlineZbias("Outline Zbias", Float) = -10.0
**********************************************
float _OutlineZbias;
********************************************
outlinDir.z = _OutlineZbias * (1.0 - v.color.b);

顶点色B通道如下:**

边缘补光(可选做,主要是Demo展示用)

新增灯光向量,和灯光颜色参数

_RimLightDir("RimLight Dir", vector) = (1,0,-1,0)
_RimLightColor("RimLight Color", Color) = (1,1,1,1)

将补充灯光转换到视图空间,使其随视角变化,当前为世界空间 unity_MatrixInvV为世界空间到视图空间逆矩阵

//补光
float3 lightDir_rim = normalize(mul((float3x3)unity_MatrixInvV ,_RimLightDir.xyz));

按照NdoL算法求出边缘光,base_mask是皮肤区域mask,toon_diffuse是控制在无光照地方不产生补光,sss_alpha控制每个区域补光强度不同

half NdotL_rim = (dot(normalDir, lightDir_rim) + 1.0) * 0.5;      
half rimlight_term = NdotL_rim  + diffuse_control;
half toon_rim = saturate((rimlight_term - _ToonThesHold) * 20); 
half3 rim_color  = (_RimLightColor.rgb + base_color) * 0.5 * sss_alpha;
half3 final_rim_color = toon_rim * rim_color * base_mask * toon_diffuse * _RimLightColor.a;

最终效果

脸部法线处理后效果 使用法线传递进行修改面部法线

修改前

修改后

完整Shader代码

Shader "Unlit/Toon"
{
    Properties
    {
        _BaseMap ("Base Map", 2D) = "white" {}
        _SSSMap ("SSS Map", 2D) = "black" {}
        _ILMMap("ILM Map", 2D) = "gray" {}
        _DetialMap("Detial Map", 2D) = "white" {}
        _ToonThesHold("ToonThesHold", Range(0, 1)) = 0.5
        _ToonHardness("ToonHardness", Float) = 20.0
        _SpecColor("SpecColor", Color) = (0.0, 0.0, 0.0, 0.0)
        _SpecSize("SpecSize", Range(0,1)) = 0.0
        _RimLightDir("RimLight Dir", vector) = (1,0,-1,0)
        _RimLightColor("RimLight Color", Color) = (1,1,1,1)
        _OutlineWidth("Outline Width",Range(0,20)) = 7.0
        _OutlineColor("Outline Color",Color) = (0.0, 0.0, 0.0, 0.0)
        _OutlineZbias("Outline Zbias", Float) = -10.0
        
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" }
        LOD 100

        Pass
        {
            Tags { "LightMode"="ForWardBase" }
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #pragma multi_compile_fwdbase
            #include "UnityCG.cginc"
            #include "AutoLight.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
                float2 texcoord0 : TEXCOORD0;
                float2 texcoord1 : TEXCOORD1;
                float3 normal : NORMAL;
                float4 color: Color;
            };

            struct v2f
            {
                float4 pos : SV_POSITION;
                float4 uv : TEXCOORD0;
                float3 pos_world : TEXCOORD1;
                float3 normal_world : TEXCOORD2;
                float4 vertex_color : TEXCOORD3;
                SHADOW_COORDS(4)
            };

            sampler2D _BaseMap;
            sampler2D _SSSMap;
            sampler2D _ILMMap;
            sampler2D _DetialMap;
            float _ToonThesHold;
            float _ToonHardness;
            float _SpecSize;
            float4 _SpecColor;
            float4 _RimLightDir;
            float4 _RimLightColor;
   

            v2f vert (appdata v)
            {
                v2f o;
                o.pos = UnityObjectToClipPos(v.vertex);
                o.uv = float4(v.texcoord0, v.texcoord1);
                o.pos_world = mul(unity_ObjectToWorld, v.vertex).xyz;
                o.normal_world = normalize(UnityObjectToWorldNormal(v.normal));
                o.vertex_color = v.color;
                TRANSFER_SHADOW(o)
                return o;
            }

            half4 frag (v2f i) : SV_Target
            {
                half2 uv1 = i.uv.xy;
                half2 uv2 = i.uv.zw;

                //向量
                half3 normalDir = normalize(i.normal_world);
                half3 lightDir = normalize(_WorldSpaceLightPos0.xyz);
                half3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.pos_world);

                // *****sample the texture******
                //Base贴图
                half4 base_map = tex2D(_BaseMap, uv1);
                half3 base_color = base_map.rgb;
                half base_mask = base_map.a; //区分皮肤区域mask
                //SSS贴图
                half4 sss_map = tex2D(_SSSMap, uv1);
                half3 sss_color = sss_map.rgb; //暗部颜色
                half sss_alpha = sss_map.a;   //边缘光强度控制
                //ILM贴图
                half4 ilm_map = tex2D(_ILMMap, uv1);
                half spec_intensity = ilm_map.r;  //高光强度
                half diffuse_control = ilm_map.g * 2.0 - 1.0; //阴影偏移控制
                half spec_size = ilm_map.b;       //高光大小
                half inner_line = ilm_map.a;      //内描线
                //Detail贴图
                half4 detail_map = tex2D(_DetialMap, uv2);//细节描线

                //AO
                float ao = i.vertex_color.r; //AO
                float atten = SHADOW_ATTENUATION(i); //阴影衰减

                //diffuse
                half NdotL = dot(normalDir, lightDir);          //-1~1结果
                half half_lambert = (NdotL + 1.0) * 0.5;        //0~1结果
                half half_lambert_term = half_lambert * ao * atten + diffuse_control;
                half toon_diffuse = saturate((half_lambert_term - _ToonThesHold) * _ToonHardness);           //色阶化(阴影二值化)
                half3 final_diffuse = lerp(sss_color, base_color, toon_diffuse);
                //spec
                float NdotV = (dot(normalDir, viewDir) + 1.0) * 0.5;
                float spec_term = NdotV * ao + diffuse_control;
                spec_term = half_lambert * 0.9 + spec_term * 0.1;
                half test = spec_size * _SpecSize;
                half toon_spec = saturate((spec_term - (1- spec_size * _SpecSize)) * 500);
                half3 spec_color  = (_SpecColor.rgb + base_color) * 0.5;
                half3 final_spec = toon_spec * spec_color * spec_intensity; 

                //描线
                half3 inner_line_color = lerp(base_color * 0.2, float3(1,1,1), inner_line);
                half3 detail_line_color = lerp(base_color * 0.5, float3(1,1,1), detail_map.xyz);
                half3 final_line = inner_line_color * detail_line_color;

                //补光
                float3 lightDir_rim = normalize(mul((float3x3)unity_MatrixInvV ,_RimLightDir.xyz));
                half NdotL_rim = (dot(normalDir, lightDir_rim) + 1.0) * 0.5;      
                half rimlight_term = NdotL_rim  + diffuse_control;
                half toon_rim = saturate((rimlight_term - _ToonThesHold) * 20); 
                half3 rim_color  = (_RimLightColor.rgb + base_color) * 0.5 * sss_alpha;
                half3 final_rim_color = toon_rim * rim_color * base_mask * toon_diffuse * _RimLightColor.a;


                half3 final_color = (final_diffuse + final_spec + final_rim_color) * final_line;
                final_color = sqrt(max(exp2(log2(max(final_color,0)) * 2.2), 0.0)); //色彩调整
                return float4(final_color, 1.0);
            }
            ENDCG
        }
        Pass
        {

            Cull Front
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "UnityCG.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
                float2 texcoord0 : TEXCOORD0;
                float2 texcoord1 : TEXCOORD1;
                float3 normal : NORMAL;
                float4 color: Color;
            };

            struct v2f
            {
                float4 pos : SV_POSITION;
                float2 uv : TEXCOORD0;
                float4 vertex_color : TEXCOORD3;
            };

            sampler2D _BaseMap;
            sampler2D _SSSMap;
            sampler2D _ILMMap;
            float _OutlineWidth;
            float4 _OutlineColor;
            float _OutlineZbias;
   

            v2f vert (appdata v)
            {
                v2f o;
                //******************世界空间处理***********
                // float3 pos_world = mul(unity_ObjectToWorld, v.vertex).xyz;
                // float3 normal_world = normalize(UnityObjectToWorldNormal(v.normal));
                // pos_world += normal_world * _Outline; 
                // o.pos = mul(UNITY_MATRIX_VP, float4(pos_world, 1.0));

                //*******************观察空间处理**************
                float3 pos_view = UnityObjectToViewPos(v.vertex);
                float3 normal_world = UnityObjectToWorldNormal(v.normal);
                float3 outlinDir = normalize(mul((float3x3)UNITY_MATRIX_V,normal_world));
                outlinDir.z = _OutlineZbias * (1.0 - v.color.b);
                pos_view += outlinDir * _OutlineWidth * 0.0001 * v.color.a; 
                o.pos = mul(UNITY_MATRIX_P, float4(pos_view, 1.0));

                o.uv = v.texcoord0;
                o.vertex_color = v.color;

                return o;
            }

            half4 frag (v2f i) : SV_Target
            {
      
                // *****sample the texture******
                //Base贴图
                half4 base_map = tex2D(_BaseMap, i.uv);
                half3 base_color = base_map.rgb;


                half maxComponent = max(max(base_color.r, base_color.g), base_color.b) - 0.004;
                half3 saturatedColor = step(maxComponent.rrr, base_color) * base_color;
                saturatedColor = lerp(base_color.rgb, saturatedColor, 0.6);
                half3 outlineColor = 0.8 * saturatedColor * base_color * _OutlineColor.xyz;

                return float4(outlineColor, 1.0);
            }
            ENDCG
        }
    }
    Fallback "Diffuse"
}