实时3D风格化渲染一直是图形学里很有意思的一个方向:既要保留传统渲染管线的性能优势,又要在画面上做出明确的“手绘感”。法国漫画大师墨比斯(Moebius,本名 Jean Giraud)的作品以极简而精准的轮廓线、大面积平涂色块、克制的排线阴影和近乎留白的构图著称,这套视觉语言如果直接用常规PBR渲染去打光,很难还原出来。
本文围绕一个可落地的实验展开:用 Sobel 滤波从深度和法线缓冲中提取轮廓,再叠加颜色量化、网点排线和纸张噪声,在 Unity URP 与 Three.js 两条链路上复刻一种接近墨比斯漫画质感的实时渲染效果。全文包含完整的 C# Renderer Feature、HLSL 全屏后处理 Shader、GLSL 版本实现、参数调校方法以及常见报错排查清单,适合有一定 Shader 基础、想入门非真实感渲染(NPR)的读者,也适合已经在做卡通渲染、想进一步做风格化描边的同学参考。
1. 背景与核心概念
1.1 墨比斯画风的视觉特征拆解
要复刻一种画风,第一步不是写代码,而是把“感觉”拆成可以量化的视觉特征。墨比斯的漫画画面通常具备这么几个稳定特征:第一,轮廓线不是均匀等宽的,主体边缘清晰,内部细节线更细更淡,形成主次分明的层次;第二,上色基本是平涂,色块边界明确,很少出现大面积渐变和高光反射;第三,阴影往往不靠明暗过渡表现,而是用疏密不同的排线或网点完成,视觉上更接近印刷品;第四,整体色调偏灰、偏冷,纸张本身带一点米黄和颗粒感。
把这四点映射到实时渲染管线上,就对应四个处理步骤:边缘检测生成轮廓线、颜色量化把渐变压成色阶、屏幕空间纹理生成排线和网点、后处理叠加噪声与色调映射。这四个步骤都属于屏幕空间后处理,也就是在场景渲染完成之后,对最终图像再做一次全屏计算。理解这个对应关系非常关键,因为后面所有参数调整,本质上都是在调节这四个环节的强度和比例。
需要说明的是,风格化渲染不可能百分之百还原某位艺术家的手绘质感,因为手绘线条带有笔压、抖动和情绪,而屏幕空间的算子输出是确定性的。我们的目标不是“一模一样”,而是抓住画面的结构特征,让观者一眼能感受到那种干净、克制、带点旧印刷感的漫画氛围。
1.2 实时风格化渲染与 NPR 的定位
非真实感渲染(Non-Photorealistic Rendering,简称 NPR)是图形学中与PBR并列的一大分支。PBR 追求物理正确,NPR 追求表达正确——画面要传达的是某种绘画语言,而不是物理光照结果。卡通渲染(Toon Shading)、水彩渲染、素描渲染、像素化渲染都属于 NPR 的范畴,本文做的漫画描边加平涂,同样属于这一支。
实时 NPR 与离线渲染的核心差异在于预算。离线渲染可以逐像素做多次采样、做复杂的美术导向滤波,实时渲染必须在每帧 16 毫秒左右的预算内完成全部计算。这就决定了实时方案必须选择计算量可控、可并行的算子,Sobel 恰好满足这个条件:它只需要 3×3 邻域的 9 次采样,卷积核固定,非常适合 GPU 并行执行。
在实际工程中,NPR 还需要考虑分辨率无关性。同一个描边参数,在 1080p 下看着刚好的线宽,到了 4K 屏上就会细得几乎看不见。因此本文的实现里会引入基于屏幕高度归一化的采样步长,让线宽在视觉上保持一致,这一点在后文参数调校部分会详细说明。
1.3 为什么选择 Sobel 而不是其他边缘算子
边缘检测的算子有很多,Roberts Cross、Prewitt、Scharr、Laplacian、Canny 都是常见选项。Roberts Cross 只用 2×2 邻域,速度快但抗噪差,斜向边缘容易断裂;Prewitt 和 Sobel 都是 3×3,区别在于 Sobel 在中心行/列的权重是 2,相当于对邻域做了一次轻微的加权平滑,因此对噪声更鲁棒;Scharr 的权重设计更优,旋转对称性更好,但在实时描边场景里与 Sobel 的视觉差异很小;Laplacian 是二阶算子,对噪声极其敏感,直接用于深度图会产生大量噪点;Canny 包含非极大值抑制和双阈值连接,效果最好但计算量最大,且难以在单 Pass 内完成。
综合下来,Sobel 是实时描边里性价比最高的选择:一次 3×3 采样就能得到方向梯度,梯度幅值直接作为边缘强度,配合阈值就能得到干净的线条。而且 Sobel 天然输出方向信息,如果后续想做“线条粗细随视角变化”的效果,还可以利用梯度方向做各向异性膨胀,这是其他算子不容易做到的。
另外要强调的是,Sobel 本身只是“检测亮度变化”,如果我们把它直接作用在颜色图上,画面里的纹理、贴图、光照渐变都会被当成边缘,描出来的线会非常脏。所以本文的做法是把 Sobel 作用在深度缓冲和法线缓冲上,这两者只包含几何信息,不受材质和光照干扰,描边结果会干净得多。这个思路在业界通常被称为“基于几何缓冲的边缘检测”,是卡通渲染描边的主流方案之一。
2. 环境准备与版本说明
2.1 Unity 侧环境与依赖
本文的 Unity 部分以 URP(Universal Render Pipeline)为基准,示例代码按 URP 14.x 的 API 编写,对应 Unity 2022.3 LTS 版本。如果你使用的是 Unity 2021 LTS(URP 12.x)或者更新版本,部分 API 名称会有差异,主要是相机颜色目标的获取方式:URP 13 之后相机颜色目标从cameraColorTarget变成了cameraColorTargetHandle,返回类型也从RenderTargetIdentifier变成了RTHandle。这两个名字在本文代码里会明确标注,遇到编译报错时优先检查这里。
创建工程时选择 3D (URP) 模板即可,模板会自动生成 URP Asset 和 Forward Renderer。工程创建完成后,需要做三件事:第一,打开 URP Asset,在 Quality 分组里勾选 Depth Texture,否则 Shader 里采样不到_CameraDepthTexture;第二,确认 Forward Renderer 上启用了 Depth Normals 相关的 Pass,或者在自定义 Renderer Feature 里通过ConfigureInput显式声明需要深度和法线;第三,把 Color Grading 模式设为 Linear,避免后处理颜色计算出现偏差。
关于依赖,本文不需要引入任何第三方包,全部基于 URP 自带能力实现。如果后续想加 Bloom 或色调映射,可以在 Volume 里叠加 URP 自带的后期效果,但要注意顺序:本文的描边后处理应该放在 Bloom 之前,否则线条会被辉光糊掉。
2.2 Web 侧环境与 Three.js 版本
Three.js 部分用于演示如何在浏览器里跑同一套算法,方便没有 Unity 环境的读者验证原理。示例基于 Three.js 的 ES Module 版本,通过 importmap 或打包工具引入。Three.js 的版本迭代较快,addons 路径在不同版本间有过调整,早期是three/examples/jsm/...,较新版本推荐three/addons/...,两者内容一致,按你的版本选择即可。
Three.js 实现描边需要解决一个 Unity 里不需要操心的问题:WebGL 默认不提供深度纹理和法线纹理。深度纹理可以通过给WebGLRenderTarget挂DepthTexture拿到,法线纹理则要用MeshNormalMaterial覆盖场景材质再渲染一遍到独立的 RenderTarget。这个“多渲染一遍”的成本在移动端需要留意,后文性能部分会给出降级方案。
浏览器建议使用较新的 Chrome 或 Edge,确保 WebGL2 可用。如果控制台提示DepthTexture format not supported,通常是显卡驱动或纹理格式选择的问题,可以尝试把深度纹理类型改为UnsignedIntType或FloatType。
2.3 示例工程目录结构
为了让代码位置清晰,先把 Unity 工程的目录结构列出来。所有脚本和 Shader 都放在Assets下的自定义目录里,避免和模板资源混在一起:
Assets/ └── MoebiusStyle/ ├── Runtime/ │ ├── MoebiusOutlineFeature.cs │ └── MoebiusOutlinePass.cs ├── Shaders/ │ └── MoebiusStylizedPost.shader ├── Materials/ │ └── MoebiusPost.mat └── Scenes/ └── DemoScene.unityThree.js 侧的目录更简单,一个index.html加一个main.js即可,Shader 以内联字符串形式写在 js 里,方便直接复制运行。
需要提醒一点:Renderer Feature 是 ScriptableObject,它的实例保存在 Renderer 资产里,改动 Shader 引用后一定要点击 Inspector 上的 Save Asset,否则重新打开工程时引用会丢失,表现为后处理效果突然消失。这个坑非常常见,后面排查部分还会提到。
3. 核心原理拆解
3.1 Sobel 算子的数学形式与梯度含义
Sobel 算子的本质是离散微分。它用两个 3×3 的卷积核分别近似图像在水平方向和垂直方向的偏导数,然后把两个方向的偏导数组合起来得到梯度幅值。水平核和垂直核的形式如下:
Gx = [ -1 0 1 ] Gy = [ -1 -2 -1 ] [ -2 0 2 ] [ 0 0 0 ] [ -1 0 1 ] [ 1 2 1 ]把这两个核分别与 3×3 邻域做逐元素乘加,得到gx和gy,梯度幅值一般用sqrt(gx*gx + gy*gy)计算,如果追求性能可以退化成abs(gx) + abs(gy),视觉差异很小但省掉一次开方。梯度幅值越大,说明该位置的邻域变化越剧烈,越可能是边缘。
这里的关键在于“变化剧烈”的定义。如果输入是颜色图,那么颜色差异大就是边缘,结果会把贴图细节全描出来;如果输入是深度图,那么深度跳变就是边缘,对应的是物体轮廓和前后遮挡关系;如果输入是法线图,那么法线方向突变就是边缘,对应的是硬边转折,比如立方体的棱、建筑的墙角。深度和法线各有所长,实践中通常把两者加权合并,这样既能有外轮廓,又能有内部结构线。
在 Shader 里实现 Sobel 时,采样九个点可以用两次循环展开,也可以手写九次采样。现代 GPU 编译器对固定次数的循环会做完全展开,性能上没有差别,但手写更直观,便于调整权重。下面这段是深度 Sobel 的核心片段,稍后在完整 Shader 里还会出现:
float DepthSobel(float2 uv, float2 texel) { float d00 = SampleLinearDepth(uv + texel * float2(-1, -1)); float d10 = SampleLinearDepth(uv + texel * float2( 0, -1)); float d20 = SampleLinearDepth(uv + texel * float2( 1, -1)); float d01 = SampleLinearDepth(uv + texel * float2(-1, 0)); float d21 = SampleLinearDepth(uv + texel * float2( 1, 0)); float d02 = SampleLinearDepth(uv + texel * float2(-1, 1)); float d12 = SampleLinearDepth(uv + texel * float2( 0, 1)); float d22 = SampleLinearDepth(uv + texel * float2( 1, 1)); float gx = -d00 - 2.0 * d01 - d02 + d20 + 2.0 * d21 + d22; float gy = -d00 - 2.0 * d10 - d20 + d02 + 2.0 * d12 + d22; return sqrt(gx * gx + gy * gy); }需要注意的是,深度值必须先做线性化再送进 Sobel。透视投影下的深度缓冲是非线性的,靠近相机的精度高、远处精度低,直接对原始深度值求差分,会导致远处的边缘强度被严重压缩,画面表现为“近处线条浓、远处线条消失”。线性化之后,深度值近似等于到相机的欧氏距离,差分才有物理意义。URP 提供的LinearEyeDepth函数就是干这件事的,配合_ZBufferParams使用即可。
3.2 深度边缘与法线边缘的差异
深度边缘和法线边缘检测出来的线条在语义上完全不同,理解这一点对调参非常重要。深度边缘反映的是“距离突变”,物体之间的遮挡关系、物体和背景的分离都会触发,所以它擅长画外轮廓;但同一物体表面如果有大的斜面,深度变化也可能是连续的,此时深度 Sobel 的输出会随角度变化而不稳定,容易出现线条断裂或者粗细不均。
法线边缘反映的是“朝向突变”,同一物体内部的硬边转折会被检测出来,比如机械零件的棱边、建筑的檐口;而对于平滑曲面,法线变化是连续的,法线 Sobel 输出会很弱,正好符合“曲面不该有内部线”的美术直觉。但法线边缘对物体和背景的分离不敏感,因为背景的法线可能被填充成某个默认值,导致轮廓线缺失。
因此工程上通用的做法是把两者加权相加:edge = depthEdge * depthWeight + normalEdge * normalWeight,再经过一个阈值函数做软化处理。阈值函数建议用smoothstep而不是硬step,因为硬阈值会让线条出现锯齿和断裂,smoothstep能给出一个过渡带,配合后面提到的抗锯齿手段,线条会平滑很多。
还有一个容易忽略的点:法线纹理的坐标空间。URP 的_CameraNormalsTexture存储的是世界空间法线编码后的值,取值范围映射到 0 到 1,采样后需要还原成 -1 到 1 才能在 Sobel 里做差分。如果忘记还原,直接拿 0 到 1 的值做差分,结果会整体偏移,线条会莫名其妙地偏向一侧。这个错误在初学阶段非常普遍,排查时可以先输出法线图看一眼,正常应该是五颜六色的,如果整体偏紫或者偏灰,多半就是空间或编码搞错了。
3.3 颜色量化与色阶控制
颜色量化(Color Quantization)是把连续的颜色值映射到有限个离散档位的操作。数学上的表达很直接:outColor = floor(color * levels) / (levels - 1),其中levels是档位数量。当levels取 4 到 6 时,画面上会形成明显的色块分区,接近漫画的平涂效果;当levels取 12 以上时,量化痕迹变弱,更接近正常的画面。
但直接对 RGB 三通道分别量化会有一个问题:色相会漂移。比如一个偏橙的肤色,量化后可能变成纯黄或者偏红,因为三个通道的量化误差不同步。解决办法是在量化前先把颜色转到 HSV 或者 Lab 空间,只对明度通道做量化,色相和饱和度保持连续。这样既能得到色块,又能保住原本的色调倾向。
另一个细节是量化的“锚点”。floor是向下取整,这意味着所有颜色都会往暗的方向偏,整体画面会变暗。更平衡的做法是用round,或者在使用floor之后做一次亮度补偿,把结果乘一个略大于 1 的系数。实践中我一般会在量化后接一个lerp,把量化结果和原始颜色按比例混合,这样可以用一个 0 到 1 的强度参数平滑控制风格化程度,方便做过渡动画或者让美术逐步调参。
量化还会放大噪点。如果原始画面有轻微的抖动或者 TAA 的残留噪点,量化之后会被放大成明显的色块跳变,看起来像脏点。所以在量化之前最好先做一次轻微模糊,或者在量化之后用一个小半径的中值滤波压一下。本文的实现里选择在量化前做一次 3×3 的均值采样,成本可控,效果也够用。
3.4 排线、网点与纸张质感
排线是漫画里表现阴影的经典手法,原理是用一组等距的平行线,线的疏密代表明暗。在屏幕空间实现排线非常简单:把屏幕坐标旋转一个角度,投影到一条轴上,取小数部分,然后做阈值判断,得到的就是等距条纹。调整条纹的频率就相当于调整线距,调整阈值就相当于调整线宽。
float Hatch(float2 screenUV, float scale, float angle, float thickness) { float2 dir = float2(cos(angle), sin(angle)); float coord = dot(screenUV * scale, dir); float stripe = frac(coord); return smoothstep(0.5 - thickness, 0.5, stripe) - smoothstep(0.5, 0.5 + thickness, stripe); }这段返回的是一个 0 到 1 的条纹遮罩,乘以一个强度系数后叠加到暗部即可。实际使用中通常会叠两层不同角度的排线,一层用于中间调,一层用于暗部,形成交叉排线的效果,这样层次更丰富,也更接近手绘。角度建议取 45 度和 -45 度,这两个角度在视觉上最不像“屏幕纹理”,不容易产生摩尔纹。
网点(Halftone)的原理类似,只是把一维条纹换成二维点阵。用屏幕坐标除以一个网格尺寸取整,得到格子坐标,再算像素到格子中心的距离,小于半径就在点内,大于就在点外。网点更适合表现大面积的中间调,而排线更适合表现方向性的阴影。
纸张质感是最后的“调味”。简单的做法是用屏幕坐标配合一个哈希函数生成噪声,乘上一个很小的系数后加到颜色上,模拟纸张的纤维颗粒。更讲究的做法是准备一张真实的纸张扫描图作为纹理,采样后与画面做正片叠底。要注意噪声强度不能太大,超过 5% 就会明显影响画面的干净程度,反而破坏了墨比斯那种克制的风格。
3.5 后处理在渲染管线中的插入位置
Render Pass Event 的选择会直接影响效果的正确性。URP 提供了一系列枚举值,常用的有BeforeRenderingTransparents、BeforeRenderingPostProcessing、AfterRenderingPostProcessing等。本文的效果需要在场景不透明物体和透明物体都渲染完成、但 URP 自带后期(Bloom、色调映射)尚未执行的位置插入,也就是BeforeRenderingPostProcessing。
选择这个位置的原因有两个。第一,描边依赖深度和法线缓冲,这两个缓冲在不透明物体渲染完成后就已经完整,此时插入可以拿到最新数据;第二,如果放在AfterRenderingPostProcessing,URP 的色调映射已经把高动态范围压缩过了,颜色量化作用在压缩后的颜色上,色阶分布会不均匀,暗部容易糊成一片。
还有一个隐藏问题:如果场景里用了后处理抗锯齿(比如 FXAA 或者 TAA),描边应该在抗锯齿之前还是之后?答案是之前。因为描边的线条本身是硬边,先描边再抗锯齿可以让线条获得平滑过渡;如果反过来,抗锯齿先把画面糊了一遍,再检测边缘,检测到的边缘会变宽变糊,线条就不锐利了。当然,如果用了 MSAA,情况会略有不同,因为 MSAA 作用于几何边缘,和后处理的顺序关系需要单独验证。
4. Unity URP 完整实战
4.1 工程准备与渲染管线配置
先确认工程里已经有 URP Asset 和 Forward Renderer。在 Project 窗口里找到Settings目录,应该能看到UniversalRenderPipelineAsset和UniversalRenderer(或ForwardRenderer)两个资产。选中 URP Asset,在 Inspector 的 Quality 分组里勾选Depth Texture,这一步是为了让_CameraDepthTexture被正确生成。
接着选中 Renderer 资产,在 Inspector 底部的 Renderer Features 列表里点击Add Renderer Feature,选择我们接下来要写的Moebius Outline Feature。刚加上时 Shader 引用是空的,需要把稍后创建的 Shader 拖进去,并点击右上角的Save保存资产。
这里有个小技巧:调试阶段可以先把 Render Pass Event 设成BeforeRenderingPostProcessing,然后在 Shader 里直接把边缘强度当颜色输出,这样能直观看到 Sobel 检测到了什么。等边缘图看起来正常了,再叠加颜色量化和排线,排错效率会高很多。
4.2 编写 Renderer Feature 的 C# 脚本
新建MoebiusOutlineFeature.cs,放在Assets/MoebiusStyle/Runtime/下。这个类继承ScriptableRendererFeature,负责持有材质、创建 Pass 实例,并在 Inspector 里暴露可调参数。
// 文件路径:Assets/MoebiusStyle/Runtime/MoebiusOutlineFeature.cs using UnityEngine; using UnityEngine.Rendering; using UnityEngine.Rendering.Universal; public class MoebiusOutlineFeature : ScriptableRendererFeature { [System.Serializable] public class Settings { public RenderPassEvent renderPassEvent = RenderPassEvent.BeforeRenderingPostProcessing; public Shader shader; [Header("Edge")] [Range(0.5f, 3.0f)] public float edgeThickness = 1.2f; [Range(0.0f, 1.0f)] public float depthThreshold = 0.08f; [Range(0.0f, 1.0f)] public float normalThreshold = 0.35f; [Range(0.0f, 1.0f)] public float edgeSoftness = 0.15f; [Header("Style")] [Range(2.0f, 16.0f)] public float colorLevels = 5.0f; [Range(0.0f, 1.0f)] public float posterizeBlend = 0.85f; [Range(0.0f, 1.0f)] public float hatchStrength = 0.30f; [Range(20.0f, 400.0f)] public float hatchScale = 160.0f; [Range(0.0f, 0.15f)] public float paperNoise = 0.04f; } public Settings settings = new Settings(); private Material material; private MoebiusOutlinePass pass; public override void Create() { if (settings.shader == null) { return; } if (material == null) { material = CoreUtils.CreateEngineMaterial(settings.shader); } pass = new MoebiusOutlinePass(material, settings); pass.renderPassEvent = settings.renderPassEvent; } public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData) { if (pass == null || material == null) { return; } // 只对 Game 视图和 Scene 视图生效,避免预览窗口报错 var cameraType = renderingData.cameraData.cameraType; if (cameraType == CameraType.Preview || cameraType == CameraType.Reflection) { return; } renderer.EnqueuePass(pass); } protected override void Dispose(bool disposing) { CoreUtils.Destroy(material); pass?.Dispose(); } }这段代码里有几个点值得说明。CoreUtils.CreateEngineMaterial是 URP 提供的工具方法,它会创建一个隐藏的材质实例,不依赖场景资源,适合 Renderer Feature 这种全局对象使用;CoreUtils.Destroy则负责在 Feature 被销毁时释放材质,避免编辑器下反复编译脚本导致内存泄漏。AddRenderPasses里对相机类型的过滤也很重要,如果不加过滤,场景视图、材质预览球、反射探针的渲染都会触发这个 Pass,预览窗口会出现奇怪的描边或者直接报错。
4.3 编写 Pass 与全屏 Blit 逻辑
新建MoebiusOutlinePass.cs。这个类继承ScriptableRenderPass,核心工作是在OnCameraSetup里申请临时 RT,在Execute里执行两次 Blit:先把相机颜色目标拷贝到临时 RT 并用材质处理,再把结果写回相机颜色目标。
// 文件路径:Assets/MoebiusStyle/Runtime/MoebiusOutlinePass.cs using UnityEngine; using UnityEngine.Rendering; using UnityEngine.Rendering.Universal; public class MoebiusOutlinePass : ScriptableRenderPass { private const string PROFILER_TAG = "Moebius Stylized Post"; private readonly Material material; private readonly MoebiusOutlineFeature.Settings settings; private RTHandle tempHandle; public MoebiusOutlinePass(Material material, MoebiusOutlineFeature.Settings settings) { this.material = material; this.settings = settings; } public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData) { // 声明本 Pass 需要深度与法线,URP 会据此生成对应缓冲 ConfigureInput(ScriptableRenderPassInput.Depth | ScriptableRenderPassInput.Normal); var descriptor = renderingData.cameraData.cameraTargetDescriptor; descriptor.depthBufferBits = 0; descriptor.msaaSamples = 1; RenderingUtils.ReAllocateIfNeeded( ref tempHandle, descriptor, FilterMode.Bilinear, TextureWrapMode.Clamp, name: "_MoebiusTemp"); } public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) { if (material == null || tempHandle == null) { return; } // 参数同步 float screenHeight = renderingData.cameraData.cameraTargetDescriptor.height; float normalizedThickness = settings.edgeThickness * (1080.0f / Mathf.Max(screenHeight, 1.0f)); material.SetFloat("_EdgeThickness", normalizedThickness); material.SetFloat("_DepthThreshold", settings.depthThreshold); material.SetFloat("_NormalThreshold", settings.normalThreshold); material.SetFloat("_EdgeSoftness", settings.edgeSoftness); material.SetFloat("_ColorLevels", settings.colorLevels); material.SetFloat("_PosterizeBlend", settings.posterizeBlend); material.SetFloat("_HatchStrength", settings.hatchStrength); material.SetFloat("_HatchScale", settings.hatchScale); material.SetFloat("_PaperNoise", settings.paperNoise); CommandBuffer cmd = CommandBufferPool.Get(PROFILER_TAG); // URP 13+ 使用 cameraColorTargetHandle;URP 12 请改用 cameraColorTarget RTHandle cameraTarget = renderingData.cameraData.renderer.cameraColorTargetHandle; Blitter.BlitCameraTexture(cmd, cameraTarget, tempHandle, material, 0); Blitter.BlitCameraTexture(cmd, tempHandle, cameraTarget); context.ExecuteCommandBuffer(cmd); CommandBufferPool.Release(cmd); } public void Dispose() { tempHandle?.Release(); } }关于分辨率归一化那行代码,逻辑是以 1080p 为基准,屏幕越高,采样步长越小,这样在 4K 屏上线条的视觉宽度和 1080p 一致。如果反过来做,4K 屏的线条会细得几乎看不见,这是很多描边实现的通病。
ConfigureInput声明了需要 Depth 和 Normal,但这并不总是足够。在某些 URP 版本和渲染器配置下,法线纹理仍需要 Renderer 上启用 Depth Normals Pass 才会生成。如果调试时发现法线采样全是默认值(比如恒定不变),可以检查 Renderer 的Depth Priming Mode设置,或者临时把ConfigureInput换成只请求 Depth,验证是不是法线缓冲没生成。
4.4 全屏后处理 Shader 实现
新建MoebiusStylizedPost.shader。这个 Shader 是全篇的核心,包含深度 Sobel、法线 Sobel、边缘合成、颜色量化、排线叠加、纸张噪声六个部分。代码使用了 URP 的Blit.hlsl,它提供了全屏顶点的标准写法,也自动处理了_BlitTexture的绑定。
// 文件路径:Assets/MoebiusStyle/Shaders/MoebiusStylizedPost.shader Shader "Moebius/StylizedPost" { Properties { _EdgeThickness ("Edge Thickness", Range(0.5, 3.0)) = 1.2 _DepthThreshold ("Depth Threshold", Range(0.0, 1.0)) = 0.08 _NormalThreshold ("Normal Threshold", Range(0.0, 1.0)) = 0.35 _EdgeSoftness ("Edge Softness", Range(0.01, 1.0)) = 0.15 _ColorLevels ("Color Levels", Range(2.0, 16.0)) = 5.0 _PosterizeBlend ("Posterize Blend", Range(0.0, 1.0)) = 0.85 _HatchStrength ("Hatch Strength", Range(0.0, 1.0)) = 0.30 _HatchScale ("Hatch Scale", Range(20.0, 400.0)) = 160.0 _PaperNoise ("Paper Noise", Range(0.0, 0.15)) = 0.04 _LineColor ("Line Color", Color) = (0.06, 0.07, 0.09, 1) _PaperTint ("Paper Tint", Color) = (0.96, 0.93, 0.86, 1) } SubShader { Tags { "RenderType" = "Opaque" "RenderPipeline" = "UniversalPipeline" } ZWrite Off ZTest Always Cull Off Pass { Name "MoebiusStylized" HLSLPROGRAM #pragma vertex Vert #pragma fragment Frag #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/DeclareDepthTexture.hlsl" #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/DeclareNormalsTexture.hlsl" #include "Packages/com.unity.render-pipelines.core/Runtime/Utilities/Blit.hlsl" float _EdgeThickness; float _DepthThreshold; float _NormalThreshold; float _EdgeSoftness; float _ColorLevels; float _PosterizeBlend; float _HatchStrength; float _HatchScale; float _PaperNoise; float4 _LineColor; float4 _PaperTint; float4 _BlitTexture_TexelSize; float SampleLinearDepth(float2 uv) { float rawDepth = SampleSceneDepth(uv); return LinearEyeDepth(rawDepth, _ZBufferParams); } float3 SampleViewNormal(float2 uv) { // URP 的 _CameraNormalsTexture 为世界空间法线,还原到 -1..1 float3 n = SampleSceneNormals(uv); return normalize(n * 2.0 - 1.0); } float DepthSobel(float2 uv, float2 texel) { float d00 = SampleLinearDepth(uv + texel * float2(-1, -1)); float d10 = SampleLinearDepth(uv + texel * float2( 0, -1)); float d20 = SampleLinearDepth(uv + texel * float2( 1, -1)); float d01 = SampleLinearDepth(uv + texel * float2(-1, 0)); float d21 = SampleLinearDepth(uv + texel * float2( 1, 0)); float d02 = SampleLinearDepth(uv + texel * float2(-1, 1)); float d12 = SampleLinearDepth(uv + texel * float2( 0, 1)); float d22 = SampleLinearDepth(uv + texel * float2( 1, 1)); float gx = -d00 - 2.0 * d01 - d02 + d20 + 2.0 * d21 + d22; float gy = -d00 - 2.0 * d10 - d20 + d02 + 2.0 * d12 + d22; // 透视除法让远近物体的深度梯度可比 float centerDepth = SampleLinearDepth(uv); return sqrt(gx * gx + gy * gy) / max(centerDepth, 1e-4); } float NormalSobel(float2 uv, float2 texel) { float3 n00 = SampleViewNormal(uv + texel * float2(-1, -1)); float3 n10 = SampleViewNormal(uv + texel * float2( 0, -1)); float3 n20 = SampleViewNormal(uv + texel * float2( 1, -1)); float3 n01 = SampleViewNormal(uv + texel * float2(-1, 0)); float3 n21 = SampleViewNormal(uv + texel * float2( 1, 0)); float3 n02 = SampleViewNormal(uv + texel * float2(-1, 1)); float3 n12 = SampleViewNormal(uv + texel * float2( 0, 1)); float3 n22 = SampleViewNormal(uv + texel * float2( 1, 1)); float3 gx = -n00 - 2.0 * n01 - n02 + n20 + 2.0 * n21 + n22; float3 gy = -n00 - 2.0 * n10 - n20 + n02 + 2.0 * n12 + n22; return sqrt(dot(gx, gx) + dot(gy, gy)); } float3 Posterize(float3 color, float levels) { levels = max(levels, 2.0); float3 quantized = floor(color * levels) / (levels - 1.0); return quantized; } float HatchMask(float2 screenUV, float scale, float angle, float thickness) { float2 dir = float2(cos(angle), sin(angle)); float coord = dot(screenUV * scale, dir); float stripe = frac(coord); float lower = smoothstep(0.5 - thickness, 0.5, stripe); float upper = smoothstep(0.5, 0.5 + thickness, stripe); return saturate(lower - upper); } float Hash21(float2 p) { p = frac(p * float2(123.34, 456.21)); p += dot(p, p + 45.32); return frac(p.x * p.y); } float4 Frag(Varyings input) : SV_Target { float2 uv = input.texcoord; float2 texel = _BlitTexture_TexelSize.xy * _EdgeThickness; float3 sceneColor = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv).rgb; // 1. 几何边缘 float depthEdge = DepthSobel(uv, texel); float normalEdge = NormalSobel(uv, texel); float edgeDepth = smoothstep(_DepthThreshold, _DepthThreshold + _EdgeSoftness, depthEdge); float edgeNormal = smoothstep(_NormalThreshold, _NormalThreshold + _EdgeSoftness, normalEdge); float edge = saturate(max(edgeDepth, edgeNormal)); // 2. 颜色量化 float3 quantized = Posterize(sceneColor, _ColorLevels); float3 stylized = lerp(sceneColor, quantized, _PosterizeBlend); // 3. 暗部排线 float luma = dot(sceneColor, float3(0.2126, 0.7152, 0.0722)); float shadowMask = saturate(1.0 - luma * 1.6); float hatch = HatchMask(uv, _HatchScale, 0.7854, 0.08); float hatch2 = HatchMask(uv, _HatchScale * 1.7, -0.7854, 0.06); float hatchTotal = saturate(hatch * 0.7 + hatch2 * 0.5); stylized *= 1.0 - hatchTotal * shadowMask * _HatchStrength; // 4. 线条叠加 stylized = lerp(stylized, _LineColor.rgb, edge); // 5. 纸张质感 float noise = Hash21(uv * 2048.0) - 0.5; stylized += noise * _PaperNoise; // 6. 纸张底色混合 stylized = lerp(stylized, stylized * _PaperTint.rgb, 0.6); return float4(stylized, 1.0); } ENDHLSL } } Fallback Off }这段 Shader 里有几个地方需要重点解释。深度 Sobel 的最后除以了centerDepth,这是为了消除透视带来的梯度偏差:远处物体的深度值大,同样的几何转折在深度图上产生的差值也大,如果不做归一化,远处物体的线条会明显比近处粗。这一除法让梯度变成相对值,远近线条的粗细就一致了。
法线 Sobel 用的是dot(gx, gx)而不是gx * gx逐分量相加,效果等价但写法更简洁,也更能表达“梯度向量的模”这个含义。返回值是标量,范围取决于法线变化幅度,通常在 0 到 2 之间,配合 0.35 左右的阈值能得到比较干净的硬边。
排线部分用了两层,一层 45 度、一层 -45 度,且第二层的频率是第一层的 1.7 倍,这样两层线不会形成规则的交叉网格,视觉上更接近手绘。shadowMask用亮度反推暗部强度,乘 1.6 是为了让中间调也带一点排线,避免只有最暗处才有纹理,整体层次会显得断裂。
_BlitTexture_TexelSize是 Blit 自动传入的变量,表示源纹理的纹素大小。用它乘以_EdgeThickness得到采样步长,这样线宽就与分辨率解耦了。如果你的 URP 版本没有自动传入这个变量,可以手动在 C# 里material.SetVector("_BlitTexture_TexelSize", new Vector4(1f/w, 1f/h, w, h))。
4.5 场景搭建与参数调校
创建材质MoebiusPost.mat,Shader 选择Moebius/StylizedPost,然后把材质对应的 Shader 拖到 Renderer Feature 的 Shader 槽位里。注意这里拖的是 Shader 资产,不是材质,两者图标不同,容易拖错。
场景搭建上,建议先用简单的几何体测试:一个立方体、一个球、一个平面,加上方向光和平行光。立方体用来检验法线边缘是否正常(棱边应该有清晰线条),球用来检验深度边缘是否正常(轮廓应该有线条,球面内部不该有线),平面用来检验大面积区域的排线效果。
调参顺序建议是:先把Posterize Blend和Hatch Strength设为 0,只保留描边,观察线条是否干净;然后调整Depth Threshold和Normal Threshold,让线条不多不少;接着逐步打开Posterize Blend,观察色阶,一般 5 到 6 档比较像漫画;最后加排线和纸张噪声,强度控制在 0.3 和 0.05 以内。
如果发现线条有锯齿,可以把Edge Softness调到 0.2 左右,或者回头检查抗锯齿设置。如果发现线条在物体边缘有断裂,通常是深度阈值太高,把Depth Threshold降到 0.05 试试。如果线条太粗、糊成一片,把Edge Thickness降到 1.0 以下。
4.6 运行结果与效果对照
在 1080p 分辨率下,用上面这套参数跑一个包含三到五个物体的室内场景,预期效果是这样的:物体外轮廓有清晰的深色线条,线宽大约 1.5 到 2 像素;立方体的棱边有独立的结构线;墙面和地面的明暗过渡被压成 4 到 5 个色阶,形成明显的色块;暗部出现斜向排线,但不会盖过主体;整体色调偏米黄,接近旧印刷品。
如果和预期有偏差,可以用一个“调试输出”技巧:在 Frag 的最后临时改成return float4(edge.xxx, 1.0),直接看边缘遮罩;或者return float4(quantized, 1.0)看量化结果。这种分离调试的方法比对着最终画面猜参数高效得多,尤其是在排查“线条为什么出现在不该出现的地方”这类问题时。
性能方面,这个 Shader 每像素大约采样 20 次(9 次深度、9 次法线、1 次颜色、1 次噪声),在中端独显上跑 1080p 几乎没有压力,移动端需要留意,后文会给出优化建议。
5. Three.js 与 GLSL 补充实现
5.1 双 RenderTarget 的数据准备
浏览器的实现思路和 Unity 完全一致,只是需要手动准备深度和法线数据。核心流程是:主相机渲染到带深度纹理的 RenderTarget,同时用MeshNormalMaterial覆盖场景材质再渲染一次到法线 RenderTarget,最后用 EffectComposer 串一个自定义 ShaderPass 做后处理。
// 文件路径:src/renderTargets.js import * as THREE from 'three'; export function createSceneTargets(width, height) { const depthTexture = new THREE.DepthTexture(width, height); depthTexture.type = THREE.UnsignedIntType; const colorTarget = new THREE.WebGLRenderTarget(width, height, { minFilter: THREE.LinearFilter, magFilter: THREE.LinearFilter, depthTexture: depthTexture, depthBuffer: true }); const normalTarget = new THREE.WebGLRenderTarget(width, height, { minFilter: THREE.LinearFilter, magFilter: THREE.LinearFilter, depthBuffer: true }); return { colorTarget, normalTarget, depthTexture }; } export function renderNormalPass(renderer, scene, camera, normalTarget) { const originOverride = scene.overrideMaterial; scene.overrideMaterial = new THREE.MeshNormalMaterial(); renderer.setRenderTarget(normalTarget); renderer.clear(); renderer.render(scene, camera); scene.overrideMaterial = originOverride; }这里有一个需要注意的点:MeshNormalMaterial输出的是视图空间法线,编码方式是把 -1 到 1 映射到 0 到 1。这和 Unity 的世界空间法线不同,但对我们来说无所谓,因为 Sobel 只关心相邻像素之间的差异,只要编码方式一致,差分结果就有效。不过如果你的场景里有大量旋转物体,视图空间法线会随着相机移动而变化,边缘检测的结果会有轻微闪烁,此时可以换成自定义 Shader 输出世界空间法线。
另外,renderer.clear()在渲染法线 Pass 之前是必要的,否则上一帧的内容会残留,表现为边缘抖动。
5.2 自定义 ShaderPass 的 GLSL 实现
ShaderPass 的 Shader 对象需要提供 uniforms、vertexShader 和 fragmentShader 三部分。顶点着色器基本是模板代码,片元着色器是核心逻辑。
// 片段着色器核心部分 uniform sampler2D tDiffuse; uniform sampler2D tDepth; uniform sampler2D tNormal; uniform vec2 uTexelSize; uniform float uEdgeThickness; uniform float uDepthThreshold; uniform float uNormalThreshold; uniform float uColorLevels; uniform float uHatchStrength; uniform float uCameraNear; uniform float uCameraFar; varying vec2 vUv; float linearizeDepth(float d) { float z = d * 2.0 - 1.0; return (2.0 * uCameraNear * uCameraFar) / (uCameraFar + uCameraNear - z * (uCameraFar - uCameraNear)); } float depthSobel(vec2 uv, vec2 texel) { float d00 = linearizeDepth(texture2D(tDepth, uv + texel * vec2(-1.0, -1.0)).x); float d10 = linearizeDepth(texture2D(tDepth, uv + texel * vec2( 0.0, -1.0)).x); float d20 = linearizeDepth(texture2D(tDepth, uv + texel * vec2( 1.0, -1.0)).x); float d01 = linearizeDepth(texture2D(tDepth, uv + texel * vec2(-1.0, 0.0)).x); float d21 = linearizeDepth(texture2D(tDepth, uv + texel * vec2( 1.0, 0.0)).x); float d02 = linearizeDepth(texture2D(tDepth, uv + texel * vec2(-1.0, 1.0)).x); float d12 = linearizeDepth(texture2D(tDepth, uv + texel * vec2( 0.0, 1.0)).x); float d22 = linearizeDepth(texture2D(tDepth, uv + texel * vec2( 1.0, 1.0)).x); float gx = -d00 - 2.0 * d01 - d02 + d20 + 2.0 * d21 + d22; float gy = -d00 - 2.0 * d10 - d20 + d02 + 2.0 * d12 + d22; float center = linearizeDepth(texture2D(tDepth, uv).x); return sqrt(gx * gx + gy * gy) / max(center, 0.0001); } float normalSobel(vec2 uv, vec2 texel) { vec3 n00 = texture2D(tNormal, uv + texel * vec2(-1.0, -1.0)).xyz * 2.0 - 1.0; vec3 n10 = texture2D(tNormal, uv + texel * vec2( 0.0, -1.0)).xyz * 2.0 - 1.0; vec3 n20 = texture2D(tNormal, uv