
1. 项目概述为什么要在Godot 4里折腾赛璐珞着色器如果你是一个独立游戏开发者或者对游戏美术风格化渲染感兴趣那么“赛璐珞着色器”这个词对你来说一定不陌生。它不是什么高深莫测的黑科技简单来说就是一种能让你的3D模型看起来像2D动画片或者漫画书里走出来的技术。想想《塞尔达传说风之杖》那种干净利落的色块或者《罪恶装备》系列那种充满张力的动画效果背后都有它的功劳。我最近在Godot 4里完整地实现了一套Cel Shader赛璐珞着色器从最基础的色阶化到边缘光、轮廓线再到一些提升表现力的“骚操作”。整个过程下来我发现Godot 4的着色器语言虽然和主流的有差异但逻辑清晰社区资源也日渐丰富是实现这类风格化效果的绝佳试验场。这个项目教程就是把我踩过的坑、试出来的参数以及背后的原理掰开揉碎了讲给你听。无论你是刚接触Shader编程的新手还是从Unity、Unreal转战过来的老鸟都能在这找到可以直接“抄作业”的完整方案并理解每一步“为什么这么做”。2. 核心思路拆解赛璐珞风格的本质是什么在动手写代码之前我们必须先搞清楚目标。赛璐珞风格Cel Shading/Toon Shading的核心目的是用有限的、离散的颜色阶跃来模拟连续的光照变化从而营造出手绘卡通或传统赛璐珞片动画的视觉感受。它反其道而行之不是追求物理真实而是追求风格化的“不真实感”。2.1 技术实现的三大支柱要实现这个目标一套完整的赛璐珞着色器通常围绕三个核心视觉特征来构建色阶化光照这是最核心的一步。将模型表面接收到的连续光照强度通常是漫反射的兰伯特或半兰伯特计算结果映射到有限的几个色阶上。比如将0到1的光照强度通过一个阶梯函数只输出0.2 0.5 0.8这三个值这样模型表面就会呈现出明显的、块状的光影分界而不是平滑的渐变。轮廓线卡通角色和物体通常有清晰的黑色轮廓线这是区别于写实渲染的显著标志。在实时渲染中生成轮廓线有多种方法比如后处理、背面膨胀法、基于深度的边缘检测等。我们通常会选择实现简单、效果稳定的方法。高光与特效卡通风格的高光通常是锐利、明亮的色块而不是柔和的光斑。此外我们可能还需要添加一些风格化的特效比如“内发光”式的边缘光或者模拟墨水渍的噪点效果来增加画面的细节和表现力。2.2 Godot 4着色器管线简介Godot 4的着色器主要写在.gdshader文件中它使用一种类似于GLSL但经过简化和集成的语言。对于3D材质我们最常使用的是空间着色器它包含了几个可编程阶段vertex()顶点着色器处理每个顶点的位置、法线等属性。fragment()片元像素着色器这是我们的主战场计算每个像素最终输出的颜色。light()光照着色器Godot一个非常强大的特性。当材质设置为ShaderMaterial并勾选“本地使用”时引擎会为每个光源调用一次这个函数允许我们完全自定义光照计算。这对于实现赛璐珞光照至关重要。理解这个管线尤其是light()函数的工作机制是我们实现自定义光照模型包括色阶化的基础。我们将主要在这个函数里“动手脚”。3. 基础色阶化光照实现让我们从最核心的部分开始把平滑的光照变成色块。这里我们采用在light()函数中处理的方式因为它能给我们最大的控制权。3.1 搭建ShaderMaterial基础框架首先创建一个新的ShaderMaterial并为其新建一个着色器脚本。// 在Shader编辑器中或在一个新的.gdshader文件中 shader_type spatial; render_mode unshaded; // 初期调试可以先取消unshaded观察基础光照 // 定义一些可供外部调节的参数 uniform float _RampLevels : hint_range(2, 8) 3; // 色阶数通常3-4级就够用了 uniform vec4 _DarkColor : source_color vec4(0.2, 0.2, 0.4, 1.0); // 暗部颜色 uniform vec4 _LightColor : source_color vec4(0.8, 0.9, 1.0, 1.0); // 亮部颜色 void fragment() { // ALBEDO是Godot中片元着色器输出的基础颜色 // 我们先设置一个基础颜色光照计算会在light()函数中叠加 ALBEDO vec3(0.5); // 一个中灰色具体颜色由纹理或uniform决定更佳 }这里的关键是render_mode unshaded;。启用后Godot将不会使用内置的物理光照模型如PBR所有光照计算都交由我们的light()函数处理。初期为了看到内置光照效果进行对比可以先注释掉这行。3.2 编写自定义光照函数实现色阶化接下来是重头戏light()函数。Godot会为每一个影响当前像素的光源调用这个函数。void light() { // 1. 计算标准兰伯特漫反射强度 // NORMAL 是当前片元的法线经过插值 // LIGHT 是当前光源的方向从片元指向光源 float diffuse dot(NORMAL, LIGHT); // 将范围从[-1, 1]映射到[0, 1]避免背光面完全黑色 diffuse diffuse * 0.5 0.5; // 这就是半兰伯特模型能让暗部更柔和 // 2. 色阶化处理 // 将连续的diffuse值0~1量化到指定的阶梯上 float rampStep 1.0 / _RampLevels; // floor函数向下取整乘以step再得到量化后的值 float quantizedDiffuse floor(diffuse * _RampLevels) * rampStep; // 可选让最低一阶不为0避免死黑 quantizedDiffuse max(quantizedDiffuse, rampStep); // 3. 根据量化后的光照强度在暗部和亮部颜色之间插值 vec3 rampColor mix(_DarkColor.rgb, _LightColor.rgb, quantizedDiffuse); // 4. 将计算的颜色贡献添加到最终输出 // DIFFUSE_LIGHT 是Godot中用于累积漫反射光照的变量 DIFFUSE_LIGHT rampColor * ATTENUATION * ALBEDO; }参数与原理详解_RampLevels控制色阶的数量。设为3就会产生阴影、中间调、高光三个明显的色块。增加级数会让过渡稍微平滑但失去卡通感减少则更加风格化。mix函数是GLSL中的线性插值函数。mix(a, b, t)当t0时返回at1时返回bt在中间则返回两者的混合值。我们用它来根据光照强度在暗色和亮色之间切换。ATTENUATION这是Godot提供的变量包含了光源的衰减、阴影等信息直接乘上能保证光照的真实性。ALBEDO这里乘上基础颜色意味着我们的色阶化光照会与物体本身的纹理或颜色相乘这样更符合物理直觉。实操心得floor函数是实现色阶化的关键但它会产生“硬边”。有时候在色阶交界处可能会看到锯齿特别是低分辨率下。一个常见的优化技巧是在量化前对diffuse值做一个平滑处理比如用smoothstep或者在量化后对不同色阶的边缘进行一点点模糊但这会稍微削弱“硬朗”的卡通感需要根据项目风格权衡。3.3 添加纹理支持与颜色控制只有纯色块显然不够。我们通常需要让模型使用贴图。// 在uniform变量区添加 uniform sampler2D _MainTex : source_color; uniform vec4 _Color : source_color vec4(1.0); void fragment() { // 从纹理取样并与色调颜色相乘 vec4 texColor texture(_MainTex, UV); ALBEDO texColor.rgb * _Color.rgb; ALPHA texColor.a * _Color.a; }现在你的模型将显示纹理并且光照会根据我们自定义的色阶规则作用在纹理颜色之上。在材质Inspector面板中你可以轻松拖入贴图并调节_RampLevels、_DarkColor、_LightColor等参数实时看到卡通渲染效果的变化。4. 轮廓线生成背面膨胀法实战轮廓线是卡通渲染的灵魂。这里我们实现最经典也最稳定的方法背面膨胀法。原理很简单用两个Pass渲染模型。第一个Pass正常渲染正面第二个Pass用背面且稍微沿法线方向膨胀的黑色网格来模拟轮廓线。4.1 多Pass渲染配置Godot的着色器可以通过render_mode和cull指令来控制渲染状态。我们修改着色器开头shader_type spatial; render_mode unshaded, cull_front; // 注意这里先设置为剔除正面渲染背面 uniform float _OutlineWidth : hint_range(0.0, 0.1) 0.02; uniform vec4 _OutlineColor : source_color vec4(0.0, 0.0, 0.0, 1.0); // 我们将轮廓线作为第一个Pass void vertex() { // 在顶点着色器中将顶点位置沿法线方向NORMAL向外膨胀 // 注意NORMAL需要转换到视图空间或世界空间这里用CAMERA_MATRIX进行变换是一种方法 vec3 normal normalize((CAMERA_MATRIX * vec4(NORMAL, 0.0)).xyz); VERTEX normal * _OutlineWidth; } void fragment() { // 轮廓线Pass只输出纯色 ALBEDO _OutlineColor.rgb; ALPHA _OutlineColor.a; }这个着色器材质现在只会渲染模型的背面因为cull_front剔除了正面并且背面被膨胀了形成了包裹在模型外围的一层“壳”我们将其渲染为黑色这就是轮廓线。4.2 整合双Pass材质但是我们还需要渲染模型本身。Godot中一个材质不能直接包含多个完整的Pass。标准的做法是创建一个MeshInstance3D。为其第一个材质槽分配上面这个轮廓线材质ShaderMaterial A。为其第二个材质槽分配另一个赛璐珞着色器材质ShaderMaterial B但这个材质的着色器需要稍作修改将render_mode中的cull_front改为cull_back或者直接去掉因为默认就是剔除背面。这样MeshInstance会先渲染膨胀的背面黑线再渲染正常的正面彩色模型黑线在边缘处超出模型的部分就形成了轮廓。注意事项这种方法在大部分情况下效果很好但有两个常见问题。第一轮廓线宽度不稳定_OutlineWidth是一个常量但在透视相机下距离相机越远的物体轮廓线视觉上会越细。更高级的做法是根据顶点深度来调整膨胀量。第二内部轮廓当模型有凹陷部分时膨胀的背面可能会穿透正面导致内部出现不该有的黑线。这通常需要通过调整法线、或者使用模板缓冲等更复杂的方法来解决。对于独立游戏和风格化项目常量宽度在多数情况下是可以接受的。优化技巧你可以将轮廓线的颜色从纯黑改为深褐色或深蓝色这样轮廓会更有层次、更融入画面。也可以尝试让轮廓线宽度随着表面曲率变化在顶点着色器中用法线变化率来计算但这需要更多的计算。5. 高级效果与细节打磨基础色块和轮廓线有了接下来我们添加一些让画面更“炫”的效果。5.1 卡通风格高光卡通高光通常是一个小而亮的色块。我们可以在light()函数中添加高光计算。// 在uniform区添加高光参数 uniform float _SpecularSize : hint_range(0.1, 1.0) 0.3; uniform float _SpecularIntensity : hint_range(0.0, 5.0) 2.0; void light() { // ... 之前的漫反射计算代码 ... // 计算视线方向 vec3 viewDir normalize(CAMERA_MATRIX[3].xyz - VERTEX); // 简易计算 // 计算半角向量 vec3 halfVec normalize(LIGHT viewDir); // 计算高光强度Blinn-Phong模型简化版 float specular pow(max(dot(NORMAL, halfVec), 0.0), 32.0 / _SpecularSize); // 对高光进行色阶化或阈值化 float specularThreshold step(0.5, specular * _SpecularIntensity); vec3 specularColor vec3(specularThreshold); // 白色高光 // 将高光贡献添加到光照输出 // Godot中通常使用SPECULAR_LIGHT累积高光但unshaded模式下可能需要加到DIFFUSE_LIGHT或自定义 DIFFUSE_LIGHT specularColor * ATTENUATION; }这里用了step函数它是一个二值化函数当s大于等于edge时返回1.0否则返回0.0。这样高光就变成了一个非0即1的硬边亮块非常卡通。_SpecularSize控制高光范围指数分母越小高光点越集中_SpecularIntensity控制高光强度阈值。5.2 边缘光Rim Light效果边缘光模拟光线从背面透过来照亮物体边缘的效果能极大地增强立体感和戏剧性。uniform float _RimPower : hint_range(0.5, 8.0) 3.0; uniform float _RimIntensity : hint_range(0.0, 2.0) 0.8; uniform vec4 _RimColor : source_color vec4(0.5, 0.8, 1.0, 1.0); void fragment() { // ... 之前的纹理采样代码 ... // 计算边缘光因子 // 法线与视线方向的点积当法线与视线垂直边缘时值接近0正面时接近1 vec3 viewDir normalize(CAMERA_MATRIX[3].xyz - VERTEX); float rimFactor 1.0 - max(dot(NORMAL, viewDir), 0.0); // 用pow函数加强边缘效果 rimFactor pow(rimFactor, _RimPower); rimFactor * _RimIntensity; // 将边缘光颜色叠加到输出上在光照计算前或后均可看需求 // 这里选择在fragment中直接加到ALBEDO上作为自发光效果 // 也可以创建一个变量累积在light()后处理 vec3 rimLight _RimColor.rgb * rimFactor; // 简单叠加 EMISSION rimLight; }EMISSION是Godot中用于自发光的输出变量。将边缘光颜色加到这里物体边缘就会产生自发光效果不受场景光照影响非常醒目。调整_RimPower可以控制边缘光的宽度值越大光带越窄。5.3 使用Ramp纹理进行更复杂的色彩控制之前我们用mix函数在两个颜色间线性插值。但更专业、更灵活的做法是使用一张一维的渐变纹理Ramp Texture作为查找表。创建Ramp纹理在图像编辑软件中创建一张细长的纹理例如256x1像素从左到右绘制你想要的暗部到亮部的颜色渐变。你可以做出非线性的、多色的渐变比如暗部偏紫中间调是固有色高光偏黄。在Shader中采样uniform sampler2D _RampTex : source_color; void light() { float diffuse ...; // 计算出的原始光照强度 // 将diffuse作为UV的横坐标U纵坐标V固定为0.5纹理中间 vec2 rampUV vec2(diffuse, 0.5); vec3 rampColor texture(_RampTex, rampUV).rgb; DIFFUSE_LIGHT rampColor * ATTENUATION * ALBEDO; }使用Ramp纹理的好处是美术同学可以直接在PS里绘制和调整色彩关系无需程序员反复修改Shader代码并重新编译工作流更加友好高效。6. 性能优化与常见问题排查一套功能完整的着色器还需要考虑性能和稳定性。以下是一些实战中总结的点。6.1 性能考量指令数我们的Shader使用了多个pow、dot和纹理采样。在移动端或低端设备上需密切关注性能。可以简化计算例如用step代替smoothstep用查表代替实时计算。多Pass开销背面膨胀法需要绘制两遍几何体对于面数极高的模型Draw Call和顶点处理开销会翻倍。对于复杂场景可以考虑使用后处理生成轮廓线如Sobel边缘检测但后处理方法难以区分物体间的轮廓且可能对透明物体处理不佳。实时阴影如果开启实时阴影轮廓线Pass也可能投射阴影造成奇怪的阴影效果。通常需要将轮廓线材质的“投射阴影”属性关闭。6.2 常见问题与解决方案速查表问题现象可能原因解决方案轮廓线断裂或不连续模型法线不连续或存在硬边。背面膨胀时相邻三角面的法线方向差异大导致膨胀后的顶点位置出现缝隙。1. 在3D软件中检查并平滑法线。2. 适当增加_OutlineWidth。3. 考虑在Shader中使用顶点法线的平均值需要预处理。轮廓线在特定角度消失相机视角与模型表面平行时膨胀的背面可能被正面完全遮挡。这是背面膨胀法的固有缺陷。可轻微增加轮廓线宽度或切换到后处理轮廓方案。色阶交界处有锯齿在低分辨率或特定角度下floor函数产生的硬边会因屏幕像素采样产生锯齿。1. 开启引擎的MSAA抗锯齿。2. 在色阶化前对diffuse值进行轻微模糊会损失一些硬度。3. 使用step函数时让阈值有一个微小的平滑过渡区。高光位置不对或闪烁高光计算依赖于视线方向viewDir计算不准确或逐像素变化太快。确保viewDir计算正确。对于移动物体可以考虑在顶点着色器计算viewDir然后插值到片元虽然精度下降但更稳定。边缘光在物体内部也出现rimFactor计算基于法线与视线夹角物体内部凹陷处也可能满足条件。在计算rimFactor前增加一个基于深度或视角深度的判断只对“真正”的边缘如深度变化大的地方生效。这通常需要结合屏幕空间后处理实现。Shader编译错误Godot Shader语法错误或使用了未定义的uniform变量。仔细检查控制台错误信息。Godot的Shader错误提示相对清晰会指明行号和问题。常见错误包括类型不匹配、缺少分号、uniform未在正确区域声明等。6.3 调试技巧Godot 4的着色器编辑器提供了强大的可视化调试功能。你可以将任何vec3颜色变量直接赋值给ALBEDO或者将标量值如diffuse构造成vec3(diffuse)来查看其灰度图分布。这是理解光照计算中间步骤的最直观方法。例如在调试色阶化时你可以临时在fragment()里写ALBEDO vec3(quantizedDiffuse); // 查看量化后的光照图这样就能在视口中直接看到黑白分明的色块分布非常方便调整_RampLevels参数。7. 完整Shader代码整合与参数调节指南将上述所有功能模块整合到一个完整、可调节的Shader中是一项系统工程。在实际项目中我建议采用模块化思路创建一个基础Cel Shader然后通过编译指令#ifdef或不同的Shader变体来开启或关闭高级功能如轮廓线、边缘光、高光等以平衡效果和性能。下面提供一个高度集成化的简化版核心框架展示了如何组织代码shader_type spatial; render_mode unshaded; // 主材质Pass用unshaded // 纹理与颜色 uniform sampler2D _MainTex : source_color; uniform vec4 _Color : source_color vec4(1.0); uniform sampler2D _RampTex : source_color; // 色阶化参数 uniform int _RampLevels : hint_range(2, 8) 3; uniform vec4 _ShadowColor : source_color vec4(0.2, 0.2, 0.4, 1.0); uniform vec4 _HighlightColor : source_color vec4(0.8, 0.9, 1.0, 1.0); // 高光参数 uniform bool _UseSpecular true; uniform float _SpecularSize 0.3; uniform float _SpecularIntensity 2.0; // 边缘光参数 uniform bool _UseRimLight true; uniform float _RimPower 3.0; uniform float _RimIntensity 0.8; uniform vec4 _RimColor : source_color vec4(0.5, 0.8, 1.0, 1.0); void fragment() { vec4 texColor texture(_MainTex, UV); ALBEDO texColor.rgb * _Color.rgb; ALPHA texColor.a * _Color.a; EMISSION vec3(0.0); } void light() { // 基础漫反射计算 float diffuse dot(NORMAL, LIGHT); diffuse diffuse * 0.5 0.5; // 色阶化 - 方法1使用Ramp纹理 vec3 rampColor; if (textureSize(_RampTex, 0).x 1) { // 简单判断纹理是否有效 rampColor texture(_RampTex, vec2(diffuse, 0.5)).rgb; } else { // 方法2使用离散色阶 float rampStep 1.0 / float(_RampLevels); float quantizedDiffuse floor(diffuse * float(_RampLevels)) * rampStep; quantizedDiffuse max(quantizedDiffuse, rampStep); rampColor mix(_ShadowColor.rgb, _HighlightColor.rgb, quantizedDiffuse); } vec3 finalLight rampColor * ATTENUATION * ALBEDO; // 高光计算 if (_UseSpecular) { vec3 viewDir normalize(CAMERA_MATRIX[3].xyz - VERTEX); vec3 halfVec normalize(LIGHT viewDir); float specular pow(max(dot(NORMAL, halfVec), 0.0), 32.0 / _SpecularSize); float specularThreshold step(0.5, specular * _SpecularIntensity); finalLight vec3(specularThreshold) * ATTENUATION; } DIFFUSE_LIGHT finalLight; } // 注意边缘光在fragment中计算因为它不依赖特定光源 void fragment() { // ... 纹理颜色计算 ... // 边缘光计算 if (_UseRimLight) { vec3 viewDir normalize(CAMERA_MATRIX[3].xyz - VERTEX); float rimFactor 1.0 - max(dot(NORMAL, viewDir), 0.0); rimFactor pow(rimFactor, _RimPower); rimFactor * _RimIntensity; EMISSION _RimColor.rgb * rimFactor; } }参数调节心法整体风格基调_ShadowColor和_HighlightColor或Ramp纹理决定了画面的色彩情绪。冷色暗部暖色亮部通常显得活泼统一色相不同明度则更稳重。轮廓线_OutlineWidth根据模型尺寸和场景比例调节0.01到0.05是常见范围。颜色不一定用纯黑深灰、深褐色或主题色相的低明度颜色可能更和谐。节奏感通过_RampLevels控制光影的对比度。级数少2-3级对比强风格化程度高级数多4-5级过渡稍柔和适合需要更多细节的角色。视觉焦点利用_RimIntensity和_SpecularIntensity引导玩家视线。将高光和边缘光强度在主角或重要道具上调高在背景物体上调低或关闭。最后别忘了将轮廓线Shader作为一个独立的Material应用到同一个MeshInstance的第二个材质槽。在Godot编辑器中你可以轻松地拖拽调整所有这些uniform参数实时预览效果这是迭代和寻找最佳艺术风格的最高效方式。