unityURP三渲二——国风水墨场景实现shader原理

一、什么是三渲二,为什么会用到三渲二

三渲二就是把三维渲成二维风格,专业上叫做NPR(Non-Photorealistic Rendering,非真实感渲染)

简单来说

就是用3D模型来做底层,用卡通化的渲染

特点是有明显的描边,色块化,光影边界清晰,没有写实级别的细腻过渡

典型的例子就有:《原神》《崩坏》《双城之战》等

为什么要用呢,肯定是为了方便哈哈

最最最简单的说法就是既有2D的风格又能有3D的制作效率和镜头发挥空间,要是2D的话有些复杂镜头的帧画面成本代价太高

关于性能

风格化渲染性能压力小,中低端配置也能实现

二、实际案例操作(网上找的素材,如有雷同,那是肯定的)

这是一套Shader Gragh双Pass三渲二描边材质的节点,风格参数为中国水墨风,有点千里江山图的味道,主要是原本的模型素材就有点画的感觉,所以具体你想做什么效果还是得自己调一下参数

1、实现方法(这只是我的,当然你有更好的也行)

整个实现的逻辑将其分为两个节点逻辑:(1)逻辑A:主体Lit材质(纹理+法线+菲涅尔)

(2)逻辑B:顶点外扩描边

这两个节点的背后shader代码都是用的HLSL语句和BRP的CG内置语句稍有区别但是不太大,整个语法还是按照ShaderLab的格式写的。

2、ShaderGraph开双pass通道(针对2020以后版本的unity版本)

(1)详细步骤:对对看,简单

  • 在Shader Gragh的Graph Setting面板里,新建一个pass,命名为Pass0_Outline(描边层)
  • 原来默认的Pass改名为Pass1_Main(主体层)
  • 调整Pass顺序:Pass0_Outline 放在最上面,Pass1_Main放在下面(Unity 会从上到下依次渲染)。

(2)设置描边节点

额外的设置:Fragment 节点的 Metallic 设为 0,Smoothness 设为 0。

描边 Pass 里不要连接法线、平滑度等其他通道,只输出 Position 和 Base Color

描边 Pass 的渲染设置:

在 Pass0_Outline 的 Graph Settings 里修改:

Render Face: Back(只渲染背面,避免正面描边被主体挡住)记住因为和另外一种方法稍有区别

ZWrite: On

ZTest: LEqual

Blend: Off

Cull Mode: Back

整个节点背后的原理:

(1)计算逻辑:

把Normal Vector和Float参数相乘,得到外扩向量

外扩向量=法线方向x描边强度

这个向量的方向和法线方向一致,长度由描边强度决定

把原始的position和上一步算出的外扩向量相加

新顶点位置=原始顶点位置+外扩向量

这一步就是让每个顶点都沿着自己的法线方向向外移动一段距离,把整个模型都放大了一圈

连接到Vertex-》Position,Unity 就会用这个外扩后的顶点位置来渲染这一层模型。

Color参数直接连接到Fragment-》BaseColor,这意味着这个描边层的模型会被填充为纯色,一般为黑灰色,没有任何纹理,只用来显示轮廓

// 属性声明(描边 Pass 专属参数)
//HLSL 固定语法,开启材质参数缓冲区
CBUFFER_START(UnityPerMaterial)
    float _OutlineWidth;
    float4 _OutlineColor;
CBUFFER_END
//简单理解就是告诉 Shader:我有两个可调参数 —— 描边宽度、描边颜色。



struct Attributes
{
    float3 positionOS   : POSITION;
    float3 normalOS     : NORMAL;
//顶点着色器的 “输入数据”,表示:从 CPU / 模型网格中,传给顶点着色器哪些信息。
//OS不是内心OS哦,是指模型空间语言Object Space
};



//顶点着色器 → 片元着色器 的 “传递数据”,也是 GPU 裁剪、屏幕映射必须的核心数据。
struct Varyings
{
    float4 positionHCS  : SV_POSITION;
};



// Vertex Shader(描边 Pass 核心:顶点外扩)
Varyings VertOutline(Attributes input)
{
    Varyings output;
    // 1. 法线方向外扩顶点
    float3 positionOS = input.positionOS + input.normalOS * _OutlineWidth;
    // 2. 转换到裁剪空间
    output.positionHCS = TransformObjectToHClip(positionOS);
//OS到HCS也就是模型空间坐标到裁剪空间坐标
    return output;
}



// Fragment Shader(描边 Pass:纯色输出)
half4 FragOutline(Varyings input) : SV_Target
{
    // 直接输出描边颜色,不需要任何纹理/光照计算
    return _OutlineColor;
}

(3)设置 主体 Pass(MainPass)节点

连接之前在 Shader Graph 顶部的 Pass 下拉菜单,选择 MainPass,切换到主体 Pass 的编辑界面。

主体 Pass 的渲染设置(在 MainPass 的 Graph Settings 里修改):

Material:保持 Lit

Render Face:Front(只渲染正面)

Depth Write:On

Depth Test:LEqual

Cull Mode:Back

Cast Shadows:勾选

注意注意:主体 Pass 的 Vertex.Position 直接用默认的 Position (Object Space),不要做任何偏移。

整个节点背后的逻辑(相信学计算机的同学对菲涅尔应该都不陌生啦):

// 属性声明(对应你 Graph 里的参数)
CBUFFER_START(UnityPerMaterial)
    sampler2D _BaseMap;
    float4 _BaseMap_ST;
    float4 _BaseMapColor;
    sampler2D _NormalMap;
    float _NormalIntensity;
    float _Smoothness;
    float _FresnelPower;
    float _FresnelIntensity;
    float4 _FresnelColor;
CBUFFER_END

struct Attributes
{
    float3 positionOS   : POSITION;
    float2 uv           : TEXCOORD0;
    float3 normalOS     : NORMAL;
    float4 tangentOS    : TANGENT;
};

struct Varyings
{
    float4 positionHCS  : SV_POSITION;
    float2 uv           : TEXCOORD0;
    float3 normalWS     : TEXCOORD1;
    float3 tangentWS    : TEXCOORD2;
    float3 bitangentWS  : TEXCOORD3;
    float3 positionWS   : TEXCOORD4;
};

// Vertex Shader(主体 Pass)
Varyings VertMain(Attributes input)
{
    Varyings output;
    // 顶点位置(不做外扩,保持原始)
    output.positionHCS = TransformObjectToHClip(input.positionOS);
    output.positionWS = TransformObjectToWorld(input.positionOS);
    
    // UV 平铺与偏移
    output.uv = TRANSFORM_TEX(input.uv, _BaseMap);
    
    // 法线/切线转换
    output.normalWS = TransformObjectToWorldNormal(input.normalOS);
    output.tangentWS = TransformObjectToWorldDir(input.tangentOS.xyz);
    output.bitangentWS = cross(output.normalWS, output.tangentWS) * input.tangentOS.w * GetOddNegativeScale();
    
    return output;
}

// Fragment Shader(主体 Pass)
half4 FragMain(Varyings input) : SV_Target
{
    // 1. 基础纹理采样 + 颜色 tint
    half4 baseMap = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv);
    half3 baseColor = baseMap.rgb * _BaseMapColor.rgb;
    
    // 2. 法线贴图处理
    half4 normalMap = SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, input.uv);
    half3 normalTS = UnpackNormal(normalMap);
    normalTS.xy *= _NormalIntensity;
    half3x3 TBN = half3x3(normalize(input.tangentWS), normalize(input.bitangentWS), normalize(input.normalWS));
    half3 normalWS = normalize(mul(normalTS, TBN));
    
    // 3. 菲涅尔效果计算
    float3 viewWS = GetWorldSpaceViewDir(input.positionWS);
    float NdotV = saturate(dot(normalize(normalWS), normalize(viewWS)));
    float fresnel = pow(1 - NdotV, _FresnelPower);
    float fresnelMask = 1 - step(_FresnelIntensity, fresnel); // 对应你 Step + OneMinus 逻辑
    half3 fresnelColor = lerp(0, _FresnelColor.rgb, fresnelMask);
    
    // 4. 颜色混合(Overlay 模式,对应你 Graph 的 Blend 节点)
    half3 finalColor = lerp(baseColor, 1 - 2 * (1 - baseColor) * (1 - fresnelColor), fresnelMask);
    
    // 输出 Lit 材质的各个通道
    half4 finalOutput = half4(finalColor, baseMap.a);
    // 这里的 Normal/Smoothness 会被 URP Lit 管线自动处理,Shader Graph 内部会自动生成对应输出
    
    return finalOutput;
}

核心算法就是:菲涅尔效应基础公式,来自物理光学,菲涅尔效应描述了光线在两种介质表面反射率随入射角变化的规律,在计算机图形学中,常用简化公式实现:

float fresnel = pow(1 - saturate(dot(normalize(N), normalize(V))), Power);

边缘提取(One Minus + Step)One Minus:将菲涅尔结果反转(中间亮→边缘亮)Step:通过阈值(Edge参数)将菲涅尔结果二值化,提取出清晰的边缘轮廓再经过一次One Minus,将轮廓区域转为白色,用于后续颜色叠加

边缘颜色混合(Blend + Multiply)将提取的边缘区域与Color参数混合,再与主纹理进行Overlay模式的 Blend,实现边缘发光 / 变色效果。

法线贴图模块:

float3 normalTS = tex2D(_NormalMap, uv).rgb * 2.0 - 1.0;
//法线贴图的 RGB 通道存储的是切线空间法线信息,需要从 [0,1] 范围映射到 [-1,1]:

法线强度控制:

normalTS.xy *= _NormalIntensity;
normalTS = normalize(normalTS);
//用于调整法线效果的强弱

Base Color

混合了边缘光的主纹理

模型的基础漫反射颜色

Normal

法线贴图采样结果

表面法线信息,影响光照反射

Smoothness

暴露的Smoothness属性

控制表面高光的锐利程度(0 = 粗糙,1 = 镜面)

Metallic

暴露的Metallic属性

控制金属度(0 = 非金属,1 = 金属)

Alpha

未额外连接,默认 1

透明度(此 Shader 为不透明材质)

Emission

未连接,默认 0

自发光(边缘光通过 Blend 叠加在 Base Color 实现)

(4)总结一下吧:

举个蛋糕的例子,一层pass就相当于一层蛋糕,而你已经学了很久做蛋糕了你做两层

  • 底层蛋糕 = 描边 Pass(放大一圈的纯色模型)
  • 上层蛋糕 = 主体 Pass(正常大小的带纹理模型)
  • Unity 会先烤底层,再烤上层,最后叠在一起,你看到的就是带描边的完整效果。
  • 3、如果unity版本过低没有双pass通道,那可以把这两部分节点放在两个Shader Graph上,把它的 Render Face 设为 Back,给模型挂两个材质球,Unity 会按顺序渲染:先渲染描边层,再渲染主体层,效果和多 Pass 完全一致。

    4、纯手工Shder代码(无节点)

    Shader "Custom/ToonLitWithOutline"
    {
        Properties
        {
            // 主体 Pass 参数
            _BaseMap ("Base Map", 2D) = "white" {}
            _BaseMapColor ("Base Color", Color) = (1,1,1,1)
            _NormalMap ("Normal Map", 2D) = "bump" {}
            _NormalIntensity ("Normal Intensity", Range(0, 2)) = 1
            _Smoothness ("Smoothness", Range(0,1)) = 0.5
            _FresnelPower ("Fresnel Power", Range(1, 10)) = 3
            _FresnelIntensity ("Fresnel Intensity", Range(0,1)) = 0.5
            _FresnelColor ("Fresnel Color", Color) = (1,1,1,1)
            
            // 描边 Pass 参数
            _OutlineWidth ("Outline Width", Range(0, 0.1)) = 0.02
            _OutlineColor ("Outline Color", Color) = (0,0,0,1)
        }
    
        SubShader
        {
            Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" }
            
            // --------------------------
            // 描边 Pass(先渲染)
            // --------------------------
            Pass
            {
                Name "Outline"
                Cull Front  // 只渲染背面
                ZWrite On
                ZTest LEqual
                
                HLSLPROGRAM
                #pragma vertex VertOutline
                #pragma fragment FragOutline
                #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
                
    
                // 描边 Pass HLSL 代码
                // 属性声明(描边 Pass 专属参数)
    CBUFFER_START(UnityPerMaterial)
        float _OutlineWidth;
        float4 _OutlineColor;
    CBUFFER_END
    
    struct Attributes
    {
        float3 positionOS   : POSITION;
        float3 normalOS     : NORMAL;
    };
    
    struct Varyings
    {
        float4 positionHCS  : SV_POSITION;
    };
    
    // Vertex Shader(描边 Pass 核心:顶点外扩)
    Varyings VertOutline(Attributes input)
    {
        Varyings output;
        // 1. 法线方向外扩顶点
        float3 positionOS = input.positionOS + input.normalOS * _OutlineWidth;
        // 2. 转换到裁剪空间
        output.positionHCS = TransformObjectToHClip(positionOS);
        return output;
    }
    
    // Fragment Shader(描边 Pass:纯色输出)
    half4 FragOutline(Varyings input) : SV_Target
    {
        // 直接输出描边颜色,不需要任何纹理/光照计算
        return _OutlineColor;
    }
                ENDHLSL
            }
            
    
            // --------------------------
            // 主体 Pass(后渲染,Lit 光照)
            // --------------------------
            Pass
            {
                Name "Main"
                Cull Back
                ZWrite On
                ZTest LEqual
                
                Tags { "LightMode" = "UniversalForward" }
                
                HLSLPROGRAM
                #pragma vertex VertMain
                #pragma fragment FragMain
                #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
                #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"
                
                // 主体 Pass HLSL 代码
               // 属性声明(对应你 Graph 里的参数)
               CBUFFER_START(UnityPerMaterial)
                 sampler2D _BaseMap;
                float4 _BaseMap_ST;
                float4 _BaseMapColor;
                 sampler2D _NormalMap;
                 float _NormalIntensity;
                float _Smoothness;
                float _FresnelPower;
                float _FresnelIntensity;
                 float4 _FresnelColor;
        CBUFFER_END
    
    struct Attributes
    {
        float3 positionOS   : POSITION;
        float2 uv           : TEXCOORD0;
        float3 normalOS     : NORMAL;
        float4 tangentOS    : TANGENT;
    };
    
    struct Varyings
    {
        float4 positionHCS  : SV_POSITION;
        float2 uv           : TEXCOORD0;
        float3 normalWS     : TEXCOORD1;
        float3 tangentWS    : TEXCOORD2;
        float3 bitangentWS  : TEXCOORD3;
        float3 positionWS   : TEXCOORD4;
    };
    
    // Vertex Shader(主体 Pass)
    Varyings VertMain(Attributes input)
    {
        Varyings output;
        // 顶点位置(不做外扩,保持原始)
        output.positionHCS = TransformObjectToHClip(input.positionOS);
        output.positionWS = TransformObjectToWorld(input.positionOS);
        
        // UV 平铺与偏移
        output.uv = TRANSFORM_TEX(input.uv, _BaseMap);
        
        // 法线/切线转换
        output.normalWS = TransformObjectToWorldNormal(input.normalOS);
        output.tangentWS = TransformObjectToWorldDir(input.tangentOS.xyz);
        output.bitangentWS = cross(output.normalWS, output.tangentWS) * input.tangentOS.w * GetOddNegativeScale();
        
        return output;
    }
    
    // Fragment Shader(主体 Pass)
    half4 FragMain(Varyings input) : SV_Target
    {
        // 1. 基础纹理采样 + 颜色 tint
        half4 baseMap = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv);
        half3 baseColor = baseMap.rgb * _BaseMapColor.rgb;
        
        // 2. 法线贴图处理
        half4 normalMap = SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, input.uv);
        half3 normalTS = UnpackNormal(normalMap);
        normalTS.xy *= _NormalIntensity;
        half3x3 TBN = half3x3(normalize(input.tangentWS), normalize(input.bitangentWS), normalize(input.normalWS));
        half3 normalWS = normalize(mul(normalTS, TBN));
        
        // 3. 菲涅尔效果计算
        float3 viewWS = GetWorldSpaceViewDir(input.positionWS);
        float NdotV = saturate(dot(normalize(normalWS), normalize(viewWS)));
        float fresnel = pow(1 - NdotV, _FresnelPower);
        float fresnelMask = 1 - step(_FresnelIntensity, fresnel); // 对应你 Step + OneMinus 逻辑
        half3 fresnelColor = lerp(0, _FresnelColor.rgb, fresnelMask);
        
        // 4. 颜色混合(Overlay 模式,对应你 Graph 的 Blend 节点)
        half3 finalColor = lerp(baseColor, 1 - 2 * (1 - baseColor) * (1 - fresnelColor), fresnelMask);
        
        // 输出 Lit 材质的各个通道
        half4 finalOutput = half4(finalColor, baseMap.a);
        // 这里的 Normal/Smoothness 会被 URP Lit 管线自动处理,Shader Graph 内部会自动生成对应输出
        
        return finalOutput;
    }
                
                ENDHLSL
            }
        }
        FallBack "Hidden/Universal Render Pipeline/FallbackError"
    }
    

    5、最后出效果之后看看好不好看,不好看接着调,哪里出错了问问AI

    要是有更好的方法,大家积极讨论,我还在学习阶段什么都愿意看看😆

    #unity学习#
    全部评论

    相关推荐

    不愿透露姓名的神秘牛友
    04-18 15:10
    点赞 评论 收藏
    分享
    04-17 13:58
    已编辑
    哈尔滨工业大学 Java
    有见过上来就写一个完整的线程池的吗?面试官一张嘴我差点尿了Q1:前面两个面试官已经提问了项目,咱们直接写一道题吧,线程池,不会c++可以用你会的语言。。。。PS:shit30min later。。。Q2:java21中的虚拟线程应用到你的项目中会有什么变化?PS:holy shit,前面java21没听清,就听到个虚拟线程,我没听这个概念,我人都傻了A:sorry面试官,我没有思考过这个问题。。。Q3:如果Redis的Pub/Sub因为某些原因没有传递到,你的caffeine会不会被读取到过期数据A:设计了很短的过期时间 + 引入消息队列重试机制Q4:如果Redission分布式锁的持有者宕机,看门狗没有续期,10000个QPS会全部达到DB上吗A:不会,因为锁无人持有,会有一个线程抢到锁,其他线程阻塞,等待会写,所以只有一个线程能到DB。PS:不知道为啥我说完又问了我一遍,感觉没说错啊,我就说的更详细了一点。。Q5:你试用Canal监听binlog实现ES和MySQL的一致性,如果Canal因为MySQL的Update太多导致Canal同步跟不上怎么办A:只想到了把Canal监听binlog的方式改为row,加速读取,然后对MySQL进行取舍(因为我问了下,MySQL主从是否一致,面试官说可能不一致),因为MYSQL主从同步有四个策略,当选择超半数同意才接受的方案时,如果Update操作太多,那么直接拒绝。还有考虑数据库分库分表,分担压力,避免所有更新请求打到少数数据库上。只想到这么多,前者回答的肯定不够,但是对Canal了解不多,没招了Q6:了解aqs吗,怎么实现一个ReentrantlockA:内部类继承aqs,针对state,队列进行设计实现公平锁或非公平锁,重写tryAccique和tryRelease,对外暴露接口Q7:你项目几个人做的,都是实验室项目吗?Q8:反问环节A:多久出结果,核心业务是什么,还有技术面吗?PS:一周内出结果,后面是hr面,业务关于支付等等没注意听,实习两个月之后有技术面本牛子0实习,bg:29,希望能通过吧。这是最后一个面试了,前面全挂了,牛友们可以看看我的其他帖子,分享了一些比较难的面经,真难绷
    查看6道真题和解析
    点赞 评论 收藏
    分享
    评论
    3
    2
    分享

    创作者周榜

    更多
    牛客网
    牛客网在线编程
    牛客网题解
    牛客企业服务