三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

Unity风格化渲染实战:卡通与素描渲染核心原理与URP实现

Unity风格化渲染实战:卡通与素描渲染核心原理与URP实现

1. 项目概述:从写实到风格化的艺术表达

在Unity的世界里,我们早已习惯了追求极致的真实感——PBR材质、全局光照、体积雾,一切都为了让虚拟世界无限逼近现实。但有时候,艺术恰恰需要跳出“真实”的框架。你是否想过,如何让一个3D角色看起来像从漫画书里走出来,或者让整个场景呈现出铅笔素描的质感?这就是非真实感渲染(Non-Photorealistic Rendering, NPR)的魅力所在。

NPR,或者说风格化渲染,其核心目标并非模拟物理世界,而是模拟艺术家的笔触和风格。它通过一系列算法和技术,将三维模型渲染成具有特定艺术风格(如卡通、素描、水彩、油画)的二维图像。这种技术在游戏、动画、影视和交互艺术中应用广泛,从《塞尔达传说:旷野之息》的清新卡通,到《大神》的水墨风格,再到许多独立游戏中独特的视觉呈现,都离不开NPR技术的支撑。

本章我们将聚焦于Unity中两种最经典且实用的NPR风格:卡通渲染和素描渲染。我会带你从原理出发,手把手实现核心的着色器效果,并分享在实际项目中优化和调试这些风格化效果的经验与坑点。无论你是想为你的独立游戏增添独特的视觉标签,还是希望在技术美术的道路上深入探索,这篇文章都将提供一套可直接落地的实战方案。

2. 卡通渲染核心原理与实现方案

卡通渲染,常被称为Cel Shading或Toon Shading,其视觉特征非常鲜明:色块分明、阴影锐利、带有轮廓线。它通过简化光影的连续过渡,用几个清晰的色阶来表现明暗关系,从而营造出类似手绘卡通或动画赛璐珞片的效果。

2.1 光影的阶跃化:实现色块分明感

真实感渲染中,漫反射光照的计算结果(通常用兰伯特模型,即表面法线与光线方向点积NdotL)是一个从0到1的连续值。卡通渲染的第一步,就是把这个连续的光照强度“离散化”。

核心思路:在片元着色器中,我们将计算出的NdotL值映射到一个有限的、预先定义好的颜色阶梯上。例如,我们可以定义三个阶梯:亮部、中间调、暗部。

// 在Fragment Shader中 float NdotL = dot(normalize(i.normal), normalize(_WorldSpaceLightPos0.xyz)); float ramp = 0; // 方法1:使用阶梯函数(Step Function)实现硬过渡 if (NdotL > 0.6) ramp = 1.0; // 亮部 else if (NdotL > 0.3) ramp = 0.5; // 中间调 else ramp = 0.0; // 暗部 // 方法2:使用平滑阶梯函数(Smoothstep)实现略带柔和的过渡(更常用) ramp = smoothstep(_RampThreshold - _RampSmooth, _RampThreshold + _RampSmooth, NdotL); // 通常我们会准备一张一维渐变纹理(Ramp Texture),根据NdotL采样 // float2 uv_ramp = float2(NdotL * 0.5 + 0.5, 0.5); // 将[-1,1]映射到[0,1]的纹理UV // fixed3 rampColor = tex2D(_RampTex, uv_ramp).rgb;

实操心得:直接使用if-elsestep函数虽然简单,但边缘会过于生硬,在角色转动时可能出现阴影“跳动”的现象。更优的做法是使用一张一维渐变纹理(Ramp Texture)进行采样。这张纹理从左到右定义了从暗到亮的颜色变化,你可以在Photoshop中精心绘制,控制每个色阶的宽度和过渡。这样不仅效果更可控,还能通过更换纹理快速切换不同的卡通色调风格,比如日系明亮风或美系厚重风。

高光处理:卡通风格的高光通常不是柔和的光晕,而是锐利的、形状规则的高光点或高光条。我们可以采用类似的方法,对高光强度进行阈值化处理。

// 计算高光(这里用简化版Blinn-Phong) float3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos.xyz); float3 halfDir = normalize(lightDir + viewDir); float NdotH = dot(normalize(i.normal), halfDir); float specular = pow(max(0, NdotH), _Gloss); // 对高光进行阶跃化 float specularRamp = step(_SpecularThreshold, specular); fixed3 specularColor = specularRamp * _SpecularColor.rgb;

通过调整_SpecularThreshold_Gloss(控制高光范围),你可以得到从一个小圆点到一道细长高光的不同效果,这对于表现卡通角色的头发、金属材质至关重要。

2.2 轮廓描边的五大算法与选型

轮廓线是卡通渲染的灵魂,它能将物体从背景中清晰地分离出来,强化其二维感。实现轮廓线的方法很多,各有优劣,需要根据项目需求和性能预算进行选择。

2.2.1 基于法线-视线夹角的后处理描边

这是最常用、性能相对较好的一种屏幕空间方法。其原理是:在边缘处,物体表面法线方向与摄像机视线方向近乎垂直。

  1. 第一遍渲染:将场景的法线信息和深度信息渲染到一张纹理(Normal+Depth Buffer)中。
  2. 第二遍后处理:对屏幕图像进行全屏采样。对于每个像素,采样其周围几个像素(如使用Sobel算子)的法线和深度值。
  3. 边缘检测:如果相邻像素间的法线差异或深度差异超过某个阈值,则认为该像素位于轮廓边缘。
// 片段着色器示例(后处理) fixed4 frag (v2f i) : SV_Target { // 采样当前像素和周围像素的法线、深度 float3 normalCenter = tex2D(_CameraNormalsTexture, i.uv).rgb; float depthCenter = LinearEyeDepth(SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv)); // 使用Roberts交叉算子进行简单边缘检测 float2 offsets[4] = {float2(1,1), float2(-1,1), float2(1,-1), float2(-1,-1)} * _OutlineWidth / _ScreenParams.xy; float edge = 0; for (int j = 0; j < 4; j++) { float3 normalSample = tex2D(_CameraNormalsTexture, i.uv + offsets[j]).rgb; float depthSample = LinearEyeDepth(SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv + offsets[j])); // 计算法线差异和深度差异 float normalDiff = dot(normalCenter, normalSample); float depthDiff = abs(depthCenter - depthSample); edge += (1 - normalDiff) * _NormalSensitivity; edge += depthDiff * _DepthSensitivity; } edge = saturate(edge); // 如果edge大于阈值,则输出轮廓色 return lerp(tex2D(_MainTex, i.uv), _OutlineColor, step(_EdgeThreshold, edge)); }

优点:实现简单,能捕捉到所有物体的轮廓,包括因遮挡产生的内部轮廓。缺点:描边宽度均匀,且容易受到屏幕分辨率影响,在物体运动快速时可能产生闪烁。对透明物体的处理也比较麻烦。

2.2.2 基于背面膨胀的几何描边

这是一种在模型空间操作的经典方法,利用两个Pass渲染。

  1. 第一个Pass(渲染背面轮廓):剔除正面(Cull Front),将模型顶点沿其法线方向向外膨胀一定距离,并用纯色(通常是黑色)渲染这个膨胀后的背面。这样就形成了一个比原模型稍大的“壳”。
  2. 第二个Pass(正常渲染正面):剔除背面(Cull Back),正常渲染模型本身,覆盖掉内部的大部分轮廓色,只留下边缘部分。
// Pass 1: 渲染轮廓 Cull Front ZTest Less // 确保轮廓在模型前面 CGPROGRAM #pragma vertex vert_outline #pragma fragment frag_outline v2f_outline vert_outline (appdata v) { v2f_outline o; // 将顶点沿法线方向挤出 float3 pos = v.vertex.xyz + v.normal * _OutlineWidth; o.pos = UnityObjectToClipPos(float4(pos, 1.0)); o.color = _OutlineColor; return o; } fixed4 frag_outline (v2f_outline i) : SV_Target { return i.color; } ENDCG // Pass 2: 正常渲染模型(略)

优点:描边稳定,宽度可控,且是真正的几何描边,不受屏幕分辨率影响。缺点:每个需要描边的物体都需要两个Draw Call,对性能有影响。在模型法线不均匀或存在硬边时,膨胀可能产生缝隙或断裂。对于复杂模型(如头发、树叶),效果可能不理想。

2.2.3 基于轮廓边检测的几何描边

这是一种更精确的几何方法,它直接识别并渲染模型网格中真正的“轮廓边”。轮廓边的定义是:共享这条边的两个三角形,一个面向摄像机,一个背向摄像机。

实现这种方法的Shader比较复杂,通常需要在CPU端或几何着色器中预处理模型,找出所有可能的轮廓边,然后在渲染时根据视角动态判断并渲染这些边。Unity的某些资源商店插件采用了类似思路。

优点:能生成非常干净、准确的单像素轮廓线,风格极简。缺点:实现复杂,性能开销大,且对于动态变化的模型(如蒙皮动画)需要每帧更新轮廓边数据,实用性较低。

选型建议:对于移动端或性能敏感的项目,基于法线-视线的后处理描边是平衡效果与性能的首选。对于主机或PC项目,追求稳定高质量的轮廓,基于背面膨胀的几何描边更可靠,但务必做好模型法线的平滑处理。对于特定风格(如仅需关键结构线)的静态场景,可以考虑轮廓边检测

3. 素描风格渲染的模拟与实现

素描风格渲染模拟的是铅笔或炭笔在纸上的绘画效果,其特征包括:连续的排线、笔触质感、明暗通过线条密度表现,以及纸张纹理。

3.1 色调艺术图与笔触纹理的应用

素描渲染的核心思想是用线条的疏密而非颜色的深浅来表现明暗。Praun等人提出的色调艺术图(Tonal Art Maps, TAM)是这一领域的经典方法。TAM本质上是一组预先绘制的、代表不同明暗程度的笔触纹理。

实现流程

  1. 准备TAM纹理集:你需要准备一系列(例如6-8张)灰度纹理。从最亮的(几乎全白,只有零星几点)到最暗的(线条密集,近乎全黑)。这些纹理上的笔触应该是随机的、手绘感的。
  2. 计算表面亮度:在Shader中,像往常一样计算模型表面的光照亮度(NdotL,考虑环境光等),得到一个0到1之间的值。
  3. 纹理选择与混合:根据计算出的亮度值,决定使用哪一张或哪两张TAM纹理,并进行混合。例如,亮度值0.3可能对应使用第三张纹理,亮度值0.35可能对应在第三张和第四张纹理之间进行线性插值。
  4. 叠加纸张纹理:最后,将采样到的笔触纹理与一张纸张底纹纹理进行叠加(通常使用Multiply混合模式),以增强手绘的真实感。
// 在Fragment Shader中 float brightness = CalculateLighting(i); // 计算得到亮度值,范围[0,1] // 将亮度映射到纹理索引 float texIndex = brightness * (_TAMCount - 1); float blendFactor = frac(texIndex); // 获取小数部分用于混合 int index1 = floor(texIndex); int index2 = min(index1 + 1, _TAMCount - 1); // 采样两张相邻的TAM纹理 fixed4 stroke1 = tex2D(_TAMTextures[index1], i.uv * _Tiling); fixed4 stroke2 = tex2D(_TAMTextures[index2], i.uv * _Tiling); // 根据亮度混合 fixed4 strokeColor = lerp(stroke1, stroke2, blendFactor); // 叠加纸张纹理 fixed4 paperColor = tex2D(_PaperTex, i.worldPos.xz * _PaperScale); fixed4 finalColor = strokeColor * paperColor; // 还可以根据视角或世界坐标对UV进行轻微扰动,避免纹理重复感

注意事项:TAM纹理的绘制质量直接决定最终效果。笔触的方向最好有一定变化,避免过于规律。此外,i.uv如果使用模型UV,在UV展开不好的模型上会导致笔触扭曲。一个更好的做法是使用屏幕空间坐标或世界空间坐标(如i.worldPos.xz)作为纹理坐标,这样笔触会“贴”在物体表面,随摄像机移动而移动,更像手绘效果。

3.2 屏幕空间笔划生成与扰动

除了使用预制的TAM,我们还可以尝试在屏幕空间动态生成素描线条,这能带来更动态、更风格化的效果。

一种常见方法是利用屏幕空间的后处理,结合噪声和边缘检测信息来模拟素描排线。

  1. 边缘信息:沿用或改进卡通渲染中提到的后处理边缘检测,得到场景的轮廓线和重要的内部结构线(如折痕)。
  2. 生成排线:利用屏幕空间噪声纹理(如Perlin噪声)和场景深度/法线信息,生成方向性的线条。例如,可以根据世界坐标的Y值决定线条是水平还是垂直排布。
  3. 明暗控制:将计算出的场景亮度图作为蒙版,控制线条的密度。越暗的地方,采样噪声纹理的阈值越低,从而显示出更多的线条;越亮的地方,线条越少甚至没有。
// 后处理Shader片段示例 fixed4 frag (v2f i) : SV_Target { fixed4 sceneColor = tex2D(_MainTex, i.uv); float depth = LinearEyeDepth(SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv)); float3 normal = tex2D(_CameraNormalsTexture, i.uv).rgb; // 1. 获取边缘强度 float edge = GetEdgeStrength(i.uv); // 2. 生成基于世界坐标的线条方向 float2 worldUV = GetWorldSpaceUV(i.uv, depth); float noise = tex2D(_NoiseTex, worldUV * _NoiseScale).r; // 将噪声转化为方向性线条,例如sin波 float line = sin(worldUV.y * _LineFrequency + _Time.y * _LineSpeed) * 0.5 + 0.5; line = step(noise, line); // 用噪声阈值化线条 // 3. 结合亮度控制线条可见性 float luminance = Luminance(sceneColor.rgb); float lineVisibility = (1.0 - luminance) * _LineDensity; // 越暗线条越密 line = line * lineVisibility; // 4. 结合边缘和内部线条 float finalSketch = max(edge, line); // 最终颜色:用线条强度去lerp原色和笔触色(如棕色) fixed3 finalColor = lerp(sceneColor.rgb, _StrokeColor.rgb, finalSketch); // 混合纸张纹理 finalColor *= tex2D(_PaperTex, i.uv * _PaperScale).rgb; return fixed4(finalColor, sceneColor.a); }

这种方法灵活性极高,可以通过调整噪声纹理、频率、速度等参数,创造出从工整的工程制图到狂放的速写等多种素描风格。

4. 实战:在URP中构建完整的卡通角色Shader

了解了原理,我们动手在Unity的通用渲染管线(URP)中,构建一个功能完整的卡通角色Shader。URP的Shader编写结构与内置管线有所不同,更模块化。

4.1 URP Lit Shader框架与属性定义

首先,在Unity中创建一个Unlit Shader(或从URP Lit Shader模板修改),因为它给我们最大的控制权。我们定义所需属性:

Properties { [MainTexture] _BaseMap("Albedo (RGB)", 2D) = "white" {} [MainColor] _BaseColor("Base Color", Color) = (1,1,1,1) // 卡通色阶 _RampTex("Ramp Texture", 2D) = "white" {} _RampSmooth("Ramp Smoothness", Range(0, 0.5)) = 0.1 _ShadowColor("Shadow Color", Color) = (0.5,0.5,0.5,1) // 高光 _SpecularColor("Specular Color", Color) = (1,1,1,1) _SpecularThreshold("Specular Threshold", Range(0,1)) = 0.9 _SpecularGloss("Specular Gloss", Range(1, 256)) = 64 // 轮廓线(背面膨胀法) _OutlineColor("Outline Color", Color) = (0,0,0,1) _OutlineWidth("Outline Width", Range(0, 0.1)) = 0.03 // 边缘光(Rim Light) _RimColor("Rim Color", Color) = (1,1,1,1) _RimPower("Rim Power", Range(0.1, 10)) = 5 _RimThreshold("Rim Threshold", Range(0,1)) = 0.5 }

4.2 编写顶点与片元着色器逻辑

我们需要两个SubShader Pass:一个用于渲染轮廓,一个用于渲染模型本身。

Pass 0: 轮廓Pass这个Pass非常简单,只做顶点膨胀和输出固定颜色。

SubShader { Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" } // Pass 0: Outline Pass Pass { Name "Outline" Tags { "LightMode"="SRPDefaultUnlit" } // 使用一个不被光照影响的LightMode Cull Front // 剔除正面,渲染背面 HLSLPROGRAM #pragma vertex vert_outline #pragma fragment frag_outline #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" float _OutlineWidth; half4 _OutlineColor; struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; }; struct Varyings { float4 positionHCS : SV_POSITION; }; Varyings vert_outline(Attributes IN) { Varyings OUT; VertexPositionInputs vertexInput = GetVertexPositionInputs(IN.positionOS.xyz); // 将法线从物体空间转换到观察空间(View Space)再进行挤出,效果更稳定 VertexNormalInputs normalInput = GetVertexNormalInputs(IN.normalOS); float3 normalVS = TransformWorldToViewDir(normalInput.normalWS); // 在观察空间挤出顶点 float3 posVS = TransformWorldToView(vertexInput.positionWS); posVS += normalize(normalVS) * _OutlineWidth; // 转换到齐次裁剪空间 OUT.positionHCS = TransformWViewToHClip(posVS); return OUT; } half4 frag_outline(Varyings IN) : SV_Target { return _OutlineColor; } ENDHLSL }

Pass 1: 主着色Pass这个Pass包含完整的卡通光照计算。

// Pass 1: Main Toon Shading Pass Pass { Name "ForwardLit" Tags { "LightMode"="UniversalForward" } Cull Back ZTest LEqual ZWrite On HLSLPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile _ _MAIN_LIGHT_SHADOWS #pragma multi_compile _ _MAIN_LIGHT_SHADOWS_CASCADE #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl" // ... 属性变量声明(同上) ... struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; float2 texcoord : TEXCOORD0; }; struct Varyings { float4 positionHCS : SV_POSITION; float2 uv : TEXCOORD0; float3 normalWS : TEXCOORD1; float3 positionWS : TEXCOORD2; float3 viewDirWS : TEXCOORD3; }; Varyings vert(Attributes IN) { Varyings OUT; VertexPositionInputs vertexInput = GetVertexPositionInputs(IN.positionOS.xyz); VertexNormalInputs normalInput = GetVertexNormalInputs(IN.normalOS); OUT.positionHCS = vertexInput.positionCS; OUT.uv = TRANSFORM_TEX(IN.texcoord, _BaseMap); OUT.normalWS = normalInput.normalWS; OUT.positionWS = vertexInput.positionWS; OUT.viewDirWS = GetWorldSpaceNormalizeViewDir(vertexInput.positionWS); return OUT; } half4 frag(Varyings IN) : SV_Target { // 采样基础纹理 half4 baseColor = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, IN.uv) * _BaseColor; // 获取主光源 Light mainLight = GetMainLight(TransformWorldToShadowCoord(IN.positionWS)); float3 lightDir = mainLight.direction; float3 lightColor = mainLight.color; // 计算漫反射 (NdotL) float NdotL = dot(normalize(IN.normalWS), lightDir); NdotL = NdotL * 0.5 + 0.5; // 映射到[0,1] // 使用Ramp纹理进行阶跃化 float2 rampUV = float2(NdotL, 0.5); half3 rampColor = SAMPLE_TEXTURE2D(_RampTex, sampler_RampTex, rampUV).rgb; // 计算漫反射颜色 half3 diffuse = baseColor.rgb * rampColor * lightColor; // 计算高光 (Blinn-Phong) float3 viewDir = normalize(IN.viewDirWS); float3 halfDir = normalize(lightDir + viewDir); float NdotH = dot(normalize(IN.normalWS), halfDir); float specular = pow(saturate(NdotH), _SpecularGloss); specular = step(_SpecularThreshold, specular); // 阈值化 half3 specularColor = specular * _SpecularColor.rgb * lightColor; // 计算边缘光 (Rim Light) float rim = 1.0 - saturate(dot(viewDir, normalize(IN.normalWS))); rim = smoothstep(_RimThreshold, 1.0, rim); rim = pow(rim, _RimPower); half3 rimColor = rim * _RimColor.rgb; // 合并所有光照成分 half3 finalColor = diffuse + specularColor + rimColor; // 应用阴影(URP的阴影是屏幕空间,这里简单乘上阴影衰减) half shadowAttenuation = mainLight.shadowAttenuation; finalColor *= shadowAttenuation; // 添加环境光 half3 ambient = SampleSH(IN.normalWS); finalColor += baseColor.rgb * ambient; return half4(finalColor, baseColor.a); } ENDHLSL } }

这个Shader已经具备了卡通渲染的核心要素:基于Ramp纹理的阶跃漫反射、阈值化高光、轮廓线以及增强立体感的边缘光。你可以将其应用到角色模型上,并通过调整Ramp纹理和各项参数来获得理想的卡通效果。

5. 性能优化与常见问题排查

风格化渲染虽然视觉效果独特,但在性能上也可能带来挑战,尤其是在移动平台或需要渲染大量角色的场景中。

5.1 移动端NPR性能优化策略

  1. 精简计算

    • 避免复杂光照模型:卡通渲染通常不需要计算复杂的镜面反射BRDF。使用简单的NdotL和阈值判断即可。
    • 简化轮廓算法:在移动端,优先考虑基于法线的后处理描边。虽然效果可能略逊于几何描边,但它是一个全屏效果,Draw Call不随物体数量增加而增加。如果必须用几何描边,确保轮廓Pass尽可能简单(只做顶点变换和输出颜色)。
    • 慎用屏幕空间效果:素描风格的后处理排线生成对性能消耗较大。可以考虑将其简化为在物体空间使用一张平铺的、带有方向性的噪声纹理来模拟。
  2. 利用Shader变体与多级细节(LOD)

    • 为不同性能等级的设备准备不同复杂度的Shader变体。例如,低配设备关闭高光、边缘光,使用更简单的2阶色阶。
    • 为远处的模型使用更简单的Shader,甚至切换到非NPR的简单漫反射Shader。
  3. 纹理优化

    • Ramp纹理和TAM纹理通常尺寸很小(如128x1或256x256),确保它们被正确压缩(如ASTC)。
    • 将多个角色的基础色、高光遮罩等纹理合并到一张图集(Atlas)中,减少纹理采样次数。
  4. 批处理与GPU Instancing

    • 确保使用相同Shader和材质的角色模型能够进行动态批处理或GPU Instancing。这需要Shader支持,并在属性中声明[PerRendererData]的材质属性要谨慎使用。

5.2 常见视觉问题与修复方案

  1. 轮廓线断裂或闪烁

    • 问题:使用背面膨胀法时,在模型的硬边或法线不连续处,轮廓线会出现断裂。
    • 排查:检查模型的法线。在3D建模软件中确保使用了平滑着色(Smooth Shading),或者导入Unity后勾选模型的“平滑法线”选项。对于无法平滑的硬边,可以考虑在Shader中,在挤出轮廓前对顶点法线进行简单的平滑处理(如取相邻面的平均法线),但这需要在顶点数据中提供更多信息。
    • 临时方案:轻微增加_OutlineWidth,或切换到后处理描边。
  2. 色阶过渡生硬,出现“带状”瑕疵

    • 问题:卡通色阶之间过渡不自然,在模型曲面上能看到明显的颜色分界线。
    • 排查:检查Ramp纹理。确保它不是由几个纯色块组成,而应该是有轻微平滑过渡的渐变。在Shader中,使用smoothstep函数或在采样Ramp纹理时进行双线性过滤。
    • 调整:增加_RampSmooth参数的值,让阶梯函数有一个平滑过渡区间。
  3. 轮廓线在特定角度消失或异常

    • 问题:当摄像机与模型表面平行时,基于法线-视线夹角的轮廓线可能消失;背面膨胀的轮廓线可能穿帮。
    • 排查与解决
      • 法线-视线法:这是其固有缺陷。可以尝试结合深度差检测作为补充,确保在轮廓处深度变化剧烈也能被识别。
      • 背面膨胀法:确保轮廓Pass的深度测试(ZTest)设置为LessLEqual,并且模型本身的渲染Pass的深度写入(ZWrite)是开启的。有时需要让轮廓线稍微有一点深度偏移(Offset),以避免深度冲突(Z-fighting)。
  4. 素描线条随摄像机抖动

    • 问题:使用世界坐标或屏幕坐标生成素描线条时,线条会随着摄像机微小移动而剧烈抖动,很不自然。
    • 解决:对用于采样噪声纹理的UV坐标进行抖动(Jitter)处理。可以每帧添加一个微小的、基于时间的随机偏移,或者使用一个稳定的噪声源。更高级的做法是使用屏幕空间微分(ddx/ddyfwidth来估算当前像素在屏幕上的变化率,并据此调整线条的粗细或可见性,使其在运动时更稳定。
  5. 透明物体的NPR渲染问题

    • 问题:半透明物体(如头发、玻璃)的轮廓线渲染错误,或者内部无法正确呈现卡通色阶。
    • 解决:这是一个复杂问题。通常需要为透明物体单独编写Shader。对于轮廓,可能需要将透明物体的渲染顺序调整到不透明物体之后,并小心处理深度。对于着色,透明物体的光照计算可能需要考虑透射光,简单的NdotL可能不够,需要更复杂的模型或将其作为特殊情况处理。

实现非真实感渲染是一个不断权衡艺术效果与技术实现的过程。没有一种方案是完美的,最好的方案永远是那个最契合你项目艺术风格、性能预算和技术目标的方案。从最基本的卡通色阶和轮廓线开始,逐步叠加高光、边缘光、特殊纹理等细节,并时刻在真机上测试性能表现,你就能打造出既独特又高效的风格化视觉体验。

← 返回列表