今天第一篇讲的是「怎么把包发出去」,第二篇回到「包里装什么」。而在所有能显著提升卖相、成本又极低的手段里,Shader 是性价比最高的一档:一张 64×64 的像素图,加上二十行代码,就能同时变成「受击闪白」「被烧掉消失」「在水里晃」三种素材,而你一张图都不用重画。

对独立开发者来说,这不是炫技,是省美术预算。对用 AI 生成素材的我们更是刚需——AI 出图最大的毛病是风格飘,但用同一套 shader 处理所有素材,反而能把风格强行拉齐。

今日速览

  • CanvasItem shader 管所有 2D 绘制(Sprite2D、TextureRect、Control 全算),结构是 vertex() + fragment() + 可选 light(),比 3D 的 Spatial shader 变量少得多。
  • fragment() 里最关键的一句话:COLOR 是你要输出给屏幕的最终颜色,默认并不等于贴图颜色。不写 COLOR 就等于没改;写漏 alpha 就等于整张贴图变成方块。
  • 贴图颜色来自 texture(TEXTURE, UV);UV 是 0~1 的归一化坐标,改 UV 就是「换采样位置」,这是水波、扭曲、错位的通用手法。
  • 想让每个敌人闪白程度不同,不要 duplicate() 材质,用 instance uniform——它是逐实例的,材质还能共用一份,省 draw call 也省内存。
  • discard 是「这个像素不要了」,溶解、隐形、切洞全靠它;配合一张噪声图 + smoothstep 就能做出带发光边的消失效果。

核心学习内容

一、先搞清 fragment() 的三个输入和一个输出

Godot 官方文档把 CanvasItem 的内建变量列得很清楚,2D 场景里真正天天用的只有四个:UV(采样坐标)、TEXTURE(节点当前贴图)、COLOR(顶点色 × modulate,注意它进 fragment 时就已经带着 modulate 了)、以及可读写的 COLOR 输出。另外全局的 TIME 每帧自动走,不用你自己传。

所以最小可用 shader 长这样:

shader_type canvas_item;

void fragment() {
    COLOR = texture(TEXTURE, UV);
}

这一句的效果和「什么都不加」看起来一样,但它证明了一件事:你已经在接管像素了。后面所有效果,本质都是在这句话上做加减法。

两个新手必踩的坑,我在文档里也确认了规则:

  1. 忘乘 alpha → COLOR = vec4(1.0, 0.0, 0.0, 1.0); 会让角色变成一个红色方块,因为贴图的透明区域被你写成了不透明。正确写法永远是 vec4(新rgb, 贴图.a)。
  2. 局部变量不初始化 → 文档明确警告 shading language 的局部变量不会自动填 0,是显存里的残留值。忘记赋值会得到随机噪点,且每次运行都不一样,极难复现。

二、三件套:从「能跑」到「好看」

① 受击闪白——打击感四件套里最便宜的一件。原理是把贴图 RGB 和白色按一个 0~1 的系数混合,alpha 保持不动:

shader_type canvas_item;

instance uniform float flash : hint_range(0.0, 1.0) = 0.0;
uniform vec4 flash_color : source_color = vec4(1.0);

void fragment() {
    vec4 tex = texture(TEXTURE, UV);
    COLOR = vec4(mix(tex.rgb, flash_color.rgb, flash), tex.a);
}

GDScript 侧只写一行:sprite.set_instance_shader_parameter("flash", 1.0),再用 Tween 回到 0。instance uniform 的妙处在这里体现——同一份 ShaderMaterial 可以挂在 50 个敌人身上,每个敌人的 flash 值互不干扰。

② 溶解消失——比「淡出」高级一个档次,玩家会觉得敌人是「被烧掉」而不是「被删掉」:

shader_type canvas_item;

uniform sampler2D noise_tex : hint_default_white, filter_nearest, repeat_enable;
instance uniform float dissolve : hint_range(0.0, 1.0) = 0.0;
uniform vec4 edge_color : source_color = vec4(1.0, 0.45, 0.1, 1.0);
uniform float edge_width : hint_range(0.0, 0.3) = 0.06;

void fragment() {
    vec4 tex = texture(TEXTURE, UV);
    float n = texture(noise_tex, UV).r;
    if (n < dissolve) {
        discard;
    }
    float edge = 1.0 - smoothstep(dissolve, dissolve + edge_width, n);
    COLOR = vec4(mix(tex.rgb, edge_color.rgb, edge), tex.a);
}

三个细节值得记:smoothstep 的两个边界必须前小后大,反过来写是未定义行为(很多人贴的「反向 smoothstep」其实是错的);噪声图必须用 repeat_enable,否则 UV 超出 0~1 时边缘会突然断掉;discard 掉的像素不写深度,所以溶解中的角色要保证不被别的 shader 依赖。

③ 水面波纹——改 UV 而不是改颜色。

shader_type canvas_item;

uniform float speed = 1.0;
uniform float strength : hint_range(0.0, 0.1) = 0.02;

void fragment() {
    vec2 uv = UV;
    uv.x += sin(uv.y * 18.0 + TIME * speed * 2.0) * strength;
    uv.y += cos(uv.x * 12.0 + TIME * speed) * strength * 0.5;
    COLOR = texture(TEXTURE, uv);
}

同一个技巧换个方向就是「热浪扭曲」「水中倒影」「眩晕」。要更高级的话,把 texture(TEXTURE, uv) 换成 texture(hint_screen_texture 对应的 sampler2D, SCREEN_UV + offset),就能扭曲底下的整个画面——这是折射水面的做法。

三、性能与工程上的三条硬规矩

  1. 别每帧 new 材质。ShaderMaterial 是有编译和资源开销的对象;正确做法是场景里挂一份,运行时改参数。
  2. 首次出现会掉帧。shader 第一次被渲染时要编译,敌人第一次受击时卡一下,玩家一定会感知到。缓解办法是在加载场景里先让它渲染一帧(例如放在屏幕外实例化一次),别等到战斗高潮才编译。
  3. 像素画要 filter_nearest。写完 shader 发现画面糊了,九成是纹理过滤被设成线性了,跟你代码没关系。

🎯 DeepSeek 提示词

提示词 1 · 让 DeepSeek 写一个 canvas_item shader(不瞎编 API)

你是一名 Godot 4.3+ 的着色器工程师。请为 2D 游戏写一个 canvas_item 类型的 shader。

【需求】<在这里写你要的效果,例如:角色受击时整体泛白,0.15 秒内恢复>

【技术约束】
1. 只使用 Godot 4 真实存在的内建变量,允许清单:UV、COLOR、TEXTURE、TEXTURE_PIXEL_SIZE、SCREEN_UV、FRAGCOORD、TIME、TAU、PI、VERTEX。不要使用任何 3.x 写法(如 SCREEN_TEXTURE、hint_color、textureLod 的旧签名)。
2. uniform 必须带类型提示(hint_range / source_color / hint_default_white / filter_nearest),并给默认值。
3. 若同一效果需要在多个实例上取不同数值,使用 instance uniform,不要建议 duplicate 材质。
4. fragment() 里输出 COLOR 时必须保留贴图 alpha,禁止写出会让透明区域变不透明的代码。
5. 局部变量必须先赋值再使用。

【输出格式】
- 第一段:完整 shader 代码(```glsl 代码块),带中文注释,每行注释说明"为什么"而不是"是什么"
- 第二段:GDScript 调用示例,展示如何在 _ready 和受击回调里设置参数
- 第三段:3 条最容易踩的坑(针对这个具体效果)
- 不要输出与需求无关的额外功能

使用说明:核心是那段「允许清单」。不给清单,DeepSeek 有一定概率混进 Godot 3 的 SCREEN_TEXTURE 写法;给了清单,命中率明显上升。需求一句话即可,越具体越好(写「0.15 秒」比写「很快」有用)。

提示词 2 · Shader 报错与效果不符的排查

你是 Godot 4 着色器调试专家。我遇到问题,请按下面的结构回答。

【我的 shader 代码】
```glsl
<粘贴完整代码>

【报错信息或错误现象】 <例如:Parser Error: Expected end of statement at line 7 / 或者”角色变成纯白方块”/ “编译通过但完全没效果”>

【上下文】

  • 引擎版本:<例如 Godot 4.3>
  • 挂载节点类型:<Sprite2D / TextureRect / AnimatedSprite2D>
  • 是否用了 instance uniform:<是/否>

【请按此顺序回答】

  1. 直接原因(一句话,指到具体行号)
  2. 为什么这行会这样(解释引擎行为,不要泛泛说”语法错误”)
  3. 修正后的完整代码(可直接替换,不要只给片段)
  4. 如果编译通过但效果不对,列出 3 个最可能的排查点,按可能性排序

【禁止】

  • 不要建议”重装引擎""重启编辑器”这类无效操作
  • 不要改成 Godot 3 的写法
  • 不确定的 API 请明确说”我不确定”,不要编造

**使用说明**:这个提示词最值钱的是最后那条「不许编造」。Shader 领域的 API 幻觉率偏高,明确允许模型说「不确定」,反而能得到更可靠的答案。「编译通过但没效果」是最常见的求助,所以单独要求排查点排序。

### 提示词 3 · 把美术效果需求拆成 shader 实现方案

```text
你是 2D 游戏的技术美术(TA)。我只有一个美术效果的文字描述,请帮我拆成可实现的 shader 方案。

【效果描述】<例如:Boss 死亡时,身体从下往上化成灰烬飘散,边缘发红光,最后留下一圈冲击波>

【项目条件】
- 引擎:Godot 4,2D,CanvasItem shader
- 美术资源:单张 PNG 精灵图,无序列帧
- 目标平台:Steam(桌面),可接受中等 GPU 开销
- 我的 shader 水平:能看懂 UV 和 mix,没写过噪声

【输出格式】
1. 效果拆解表(Markdown 表格):视觉要素 | 技术手段 | 难度(1-5) | 是否必需
2. 分阶段实现路线:第一阶段能跑的最简版本 → 第二阶段加细节。每阶段给出完整 shader 代码
3. 需要的辅助资源清单(噪声图规格、粒子节点配置等),说明每一项从哪来(程序生成 / 免费素材站)
4. 性能预估:该效果在 200 个实例同屏时的风险点
5. "先砍哪个"建议:如果时间只够做第一阶段,推荐砍掉哪些要素

【约束】
- 每个技术手段都要说明为什么选它,而不是选另一个
- 不要一次给最终版,必须有可运行的中间版本

使用说明:这是三条里最能提升产出质量的。直接问「怎么写溶解 shader」只能拿到一段代码;用这个模板,你会拿到一份带优先级和砍需求建议的方案——对只有一个人的项目,知道该砍什么比知道怎么写更重要。

学习心得

今天最大的认知转变是:shader 应该被当成「素材的后处理层」,而不是特效层。

我们前面 70 多天已经踩过 AI 素材风格不统一的坑(第 71、75 天)。当时靠「风格圣经 + 锚点图 + 调色板量化」去修图,成本很高。但换个思路:如果所有精灵图在渲染时都过同一套 shader——统一的描边、统一的受击染色、统一的色调映射——那么 AI 出图的细微色差会被这层后处理压平。玩家看到的不是「这张图偏冷那张偏暖」,而是「这套游戏有一套统一的视觉语言」。

对「用 DeepSeek 做一款 Steam 游戏」这个目标来说,这条尤其重要:我们没有美术,但有 shader。一张图 × 一套 shader = 一整套视觉资产。这也正好呼应昨天定的「项目宪法」——把 shader 的命名规范、参数命名(flash / dissolve / tint)、instance uniform 的使用约定写进宪法,DeepSeek 后面生成的代码才会和现有材质对得上。

明日关注

  • 把 instance uniform 的写法补进「项目宪法」,并规定所有受击反馈统一走 flash 参数,禁止每个敌人各写一套 Tween 改 modulate。
  • 给现有 3 个敌人各挂一次闪白 + 溶解,实测第一次触发时的掉帧幅度,记录是否需要预编译处理。
  • 用提示词 3 把「Boss 死亡演出」拆成方案,只实现第一阶段,验证「先能跑再加细节」这条路是否比一次做完更快。

本文由自动化学习流水线每日生成,坚持即学即记。