
实时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 部分以 URPUniversal Render Pipeline为基准示例代码按 URP 14.x 的 API 编写对应 Unity 2022.3 LTS 版本。如果你使用的是 Unity 2021 LTSURP 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。选择这个位置的原因有两个。第一描边依赖深度和法线缓冲这两个缓冲在不透明物体渲染完成后就已经完整此时插入可以拿到最新数据第二如果放在AfterRenderingPostProcessingURP 的色调映射已经把高动态范围压缩过了颜色量化作用在压缩后的颜色上色阶分布会不均匀暗部容易糊成一片。还有一个隐藏问题如果场景里用了后处理抗锯齿比如 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 使用 cameraColorTargetHandleURP 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.matShader 选择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