第 10 章:屏幕空间技术
第 10 章:屏幕空间技术 —— 读取已有画面
学习目标:掌握 SCREEN_TEXTURE / SCREEN_UV / SCREEN_PIXEL_SIZE / FRAGCOORD 四个屏幕空间变量的语义和用法,能独立完成菜单背景模糊、热浪扭曲和放大镜三个屏幕采样式后处理效果。
场景搭建:本章三个实战各需一个场景。菜单背景模糊和热浪扭曲:分别创建一个全屏覆盖的 ColorRect 节点(与屏幕等大),为其新建 ShaderMaterial 和 canvas_item shader。放大镜:创建 Sprite2d 节点挂载 ShaderMaterial,并编写配套 GDScript 脚本用于跟随鼠标移动。
内容要点:
SCREEN_TEXTURE:GPU 当前帧已画好的画面,用hint_screen_texture声明SCREEN_UV:当前像素在屏幕上的归一化坐标(0→1),与UV的关键区别SCREEN_PIXEL_SIZE:单个屏幕像素的 UV 宽度,FRAGCOORD的"换算尺"FRAGCOORD:当前片元的屏幕像素整数坐标(xy是像素位置,z是深度)- Forward+ / Mobile / Compatibility 三种渲染器对
hint_screen_texture的行为差异 - BackBufferCopy 的 copy_mode 与多后处理效果串联机制
- 实战 1:菜单背景模糊 ——
textureLod()强制低分辨率采样 + 亮度降低 - 实战 2:热浪扭曲 —— 噪声偏移
SCREEN_UV再采样SCREEN_TEXTURE - 实战 3:放大镜效果 —— 顶点着色器计算节点屏幕中心 +
SCREEN_PIXEL_SIZE坐标换算
1. 屏幕空间:从已画好的画面里"借"像素
前 9 章,你写的所有 shader 都有一个共同的前提:每个像素独立决定自己的颜色——用数学公式算渐变色、用噪声画草地、用距离场画雷达。像素之间从未互相"看过"。
但真正的游戏特效几乎全部依赖一个能力:读取已经画好的画面,然后修改它。火焰上方的热浪扭曲——你看到的是被歪曲的"火焰后面应该出现的背景",而不是火焰本身。水下效果——你看到的是被波纹扭曲的"水底世界"。放大镜——你看到的是一块被放大的"屏幕原图"。
这三种效果的共同技术核心只有一句话:用偏移过的坐标去 SCREEN_TEXTURE 里采样。
Godot 为这种"读取已有画面"的操作提供了四个专属的内置变量和一条必须的 hint 规则。下面逐一拆开。
2. 四大屏幕内置变量
2.1 SCREEN_TEXTURE:GPU 已画好的"照片"
它是什么? SCREEN_TEXTURE 是当前帧 GPU 已经完成渲染的画面。你可以把它想象成一张"游戏截屏",在你 shader 运行的那一刻,这张截屏被当作一张纹理传进来。
为什么需要它? 没有它,你的 shader 只能看见自己的精灵纹理(TEXTURE),完全不知道屏幕其他位置画了什么。有了它,你的 shader 就获得了"上下文感知"——它能看清整个屏幕,并从任意坐标"偷"颜色来用。
怎么用? 你不是直接使用 SCREEN_TEXTURE 这个名称,而是声明一个带 hint_screen_texture 的 uniform,然后在 fragment() 中用 texture() 采样:
这行代码告诉 Godot:"请把当前帧的渲染结果塞进这个 sampler2D,我来读取它。" 没有这个 hint,你的 uniform 只是一个空纹理。
注意:
uniform变量名可以任意取(如screen、screen_tex),但全书统一使用screen_texture。
2.2 SCREEN_UV:屏幕上的归一化坐标
它是什么? SCREEN_UV 是当前像素在整个屏幕上的归一化坐标,范围 (0, 0) 到 (1, 1)。(0, 0) 是屏幕左上角,(1, 1) 是右下角,(0.5, 0.5) 是正中心。
为什么需要它? 当你有一个全屏覆盖的 ColorRect 节点时,它的 UV 和 SCREEN_UV 值相同。但当你用一个非全屏的小 Sprite 挂载 screen-reading shader 时,UV 是小精灵自己的纹理坐标((0,0) 到 (1,1) 覆盖整个 Sprite),而 SCREEN_UV 是这个小精灵在屏幕上的位置——两者的值完全不同。
这是一个经常让人糊涂的坑:
| 变量 | 含义 | 范围 |
|---|---|---|
UV |
当前节点自身纹理的坐标 | (0,0)→(1,1) 覆盖节点本身 |
SCREEN_UV |
当前像素在屏幕上的坐标 | (0,0)→(1,1) 覆盖整个窗口 |
读屏特效中,采样 SCREEN_TEXTURE 需要 SCREEN_UV,不是 UV。用错了坐标,你的画面会错位。
2.3 SCREEN_PIXEL_SIZE:像素的 UV 宽度
它是什么? SCREEN_PIXEL_SIZE 是一个 vec2,存储的是单个屏幕像素对应的 UV 大小。如果屏幕分辨率是 1920×1080,则 SCREEN_PIXEL_SIZE = (1.0/1920, 1.0/1080) ≈ (0.00052, 0.00093)。
为什么需要它? 它解决了 shader 编程中一个根本性的换算问题:像素坐标和 UV 坐标之间的互转。
你需要像素坐标做"取整"操作(如像素化效果中的 floor()),但采样 SCREEN_TEXTURE 需要 UV 坐标。SCREEN_PIXEL_SIZE 就是这两套坐标系之间的换算尺:
2.4 FRAGCOORD:当前片元的屏幕像素坐标
它是什么? FRAGCOORD 是一个 vec4,其中 xy 分量是当前片元在屏幕上的像素整数坐标(原点在左上角),z 分量是深度值,w 分量是 1.0 / 片元 W 分量。
为什么需要它? 当你需要做精确的像素级操作时——比如像素化、马赛克、扫描线(隔行变色)你需要在像素整数空间做取整、取模等运算。FRAGCOORD.xy 就是这套整数空间的入口。
FRAGCOORD 和 SCREEN_UV 描述的是同一个东西,但用的坐标系不同:
2.5 四个变量的关系一览
| 变量 | 类型 | 坐标系 | 核心用途 |
|---|---|---|---|
SCREEN_TEXTURE |
sampler2D(通过 uniform) |
— | 读取已渲染的画面 |
SCREEN_UV |
vec2 |
归一化 UV,(0,0)→(1,1) |
在 SCREEN_TEXTURE 中定位要采样的像素 |
SCREEN_PIXEL_SIZE |
vec2 |
每像素 UV 量 | UV ↔ 像素坐标互转 |
FRAGCOORD |
vec4 |
像素整数坐标 | 像素级取整/取模操作 |
3. BackBufferCopy
如果你在场景中挂了两个 screen-reading shader(比如先做模糊,再做热浪扭曲),默认情况下第二个 shader 看不到第一个 shader 的修改——它读到的是初始的屏幕画面。
BackBufferCopy 节点解决了这个问题。它是一个 2D 节点,位于场景树中两个 shader 之间,作用是把当前屏幕画面复制一份到后台缓冲区。它的 copy_mode 属性决定复制范围:
| copy_mode | 行为 |
|---|---|
Disabled |
不复制 |
Rect |
复制该节点的矩形区域 |
Viewport |
复制整个视口 |
典型的多效果串联布局(从下到上):
原画面
→ BackBufferCopy (copy_mode = Viewport)
→ ColorRect + 模糊 shader(读到 BackBufferCopy 提供的画面)
→ BackBufferCopy (copy_mode = Viewport)
→ ColorRect + 热浪 shader(读到模糊后的画面)
不需要 BackBufferCopy 的情况:只有一个 screen-reading shader 时,它默认读到原始画面,无需 BackBufferCopy。
4. 实战 1:菜单背景模糊
在游戏开发中,我们经常要在游戏中显示菜单,此时需要让游戏场景变得模糊,再降低亮度,然后在此背景上显示暂停菜单。我们可以使用 SCREEN_TEXTURE 读取已经渲染好游戏场景的画面,进行必要的处理。
4.1 第 1 步:最简采样 —— 确认 SCREEN_TEXTURE 工作
开始之前,先验证你的场景能正确读到屏幕画面。先用莫奈的一幅画放在TextureRect中作为屏幕画面,再其上方创建一个和屏幕等尺寸的 ColorRect,挂载 shader:
shader_type canvas_item;
uniform sampler2D screen_texture : hint_screen_texture;
void fragment() {
COLOR = texture(screen_texture, SCREEN_UV);
}
保存。如果你的 ColorRect 覆盖全屏,运行后看到的画面应该和没有 ColorRect 时完全一样——这说明 SCREEN_TEXTURE 已经成功读取屏幕。
4.2 第 2 步:模糊背景
用第 9 章学习过的 textureLod() 函数让整个背景生成模糊效果。它通过强制读取较低分辨率的 mipmap 产生模糊——速度比逐像素卷积快得多。一定要开启 filter_linear_mipmap,否则不同 LOD 之间会产生明显跳变。
uniform sampler2D screen_texture :hint_screen_texture,filter_linear_mipmap;
uniform float blur = 3.0;
void fragment() {
COLOR = textureLod(screen_texture,SCREEN_UV,blur);
}
4.3 第 3 步:整体变暗
菜单背景一般不会只有模糊,还会降低亮度。这样,原本的游戏场景会降低关注度,再放置一个暂停菜单就会更加突出。
uniform sampler2D screen_texture :hint_screen_texture,filter_linear_mipmap;
uniform float blur = 3.0;
uniform float brightness = 0.5;
void fragment() {
vec4 color = textureLod(screen_texture,SCREEN_UV,blur);
color.rgb *= brightness;
COLOR = color;
}

完整 shader 文件见 blur.gdshader。
5. 实战 2:热浪扭曲 —— 噪声偏移 + 重采样
热浪扭曲是最经典的 screen-reading 特效。原理极其简单:把 SCREEN_UV 加一个随机的微小偏移,然后用偏移后的坐标去 SCREEN_TEXTURE 采样。偏移量由噪声纹理或噪声函数驱动,并随时间滚动。
5.1 第 1 步:最简采样 —— 确认 SCREEN_TEXTURE 工作
开始之前,先验证你的场景能正确读到屏幕画面。仍然用莫奈的画作为屏幕画面,再其上方创建一个和屏幕等尺寸的 ColorRect,挂载 shader:
shader_type canvas_item;
uniform sampler2D screen_texture : hint_screen_texture;
void fragment() {
COLOR = texture(screen_texture, SCREEN_UV);
}
保存。如果你的 ColorRect 覆盖全屏,运行后看到的画面应该和没有 ColorRect 时完全一样——这说明 SCREEN_TEXTURE 在被原样回显。如果你看到的是纯白或纯黑,检查:(1) ColorRect 是否在场景树的最上层;(2) hint_screen_texture 拼写是否正确。

5.2 第 2 步:加一个固定的 UV 偏移
在采样前,给 SCREEN_UV 加一个小的偏移量:
void fragment() {
vec2 distortion = vec2(0.02, 0.0);
vec2 offset_uv = SCREEN_UV + distortion;
COLOR = texture(screen_texture, offset_uv);
}
UV.x增加了一点,就导致画面整体向左侧平移了一点点——这是最简单的"扭曲"。但它对每个像素的偏移量都一样,所以看起来只是画面移位,不是扭曲。
5.3 第 3 步:用正弦波制造空间变化
让偏移量随屏幕的纵轴位置变化:
void fragment() {
float wave = sin(SCREEN_UV.y * 10.0) * 0.02;
vec2 offset_uv = SCREEN_UV + vec2(wave, 0.0);
COLOR = texture(screen_texture, offset_uv);
}
现在不同高度的行有不同的水平偏移——画面看起来像被横向"搓皱"了。但它是静止的。
5.4 第 4 步:让扭曲随时间流动
加上 TIME:
void fragment() {
float wave = sin(SCREEN_UV.y * 10.0 + TIME * 3.0) * 0.01;
vec2 offset_uv = SCREEN_UV + vec2(wave, 0.0);
COLOR = texture(screen_texture, offset_uv);
}
波浪开始向上流动了。但正弦波太规则——真正的热浪不是完美的正弦波。这需要噪声。
5.5 第 5 步:用噪声替代正弦波 —— 成品
把正弦波换成第 7 章学过的值噪声函数。用两个不同种子噪声分别驱动 x 和 y 方向的偏移,并把偏移强度、速度参数化:
shader_type canvas_item;
uniform float distortion_amount : hint_range(0.0, 0.1) = 0.01;
uniform float distortion_speed : hint_range(0.0, 5.0) = 2.0;
uniform sampler2D screen_texture : hint_screen_texture;
float hash(vec2 p) {
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
}
float noise_2d(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
f = f * f * (3.0 - 2.0 * f);
float a = hash(i);
float b = hash(i + vec2(1.0, 0.0));
float c = hash(i + vec2(0.0, 1.0));
float d = hash(i + vec2(1.0, 1.0));
return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
}
void fragment() {
vec2 noise_uv = SCREEN_UV * 10.0
+ vec2(TIME * distortion_speed * 0.3, TIME * distortion_speed * 0.5);
vec2 distortion = vec2(
0.2* noise_2d(noise_uv),
noise_2d(noise_uv + vec2(5.2, 1.3))
);
distortion = (distortion - vec2(0.1,0.5)) * distortion_amount;
vec2 distorted_uv = SCREEN_UV + distortion;
COLOR = texture(screen_texture, distorted_uv);
}
完整 shader 文件见 heat_distortion.gdshader。
关键点回顾:
-
SCREEN_UV提供采样坐标 → 你偏移它 → 用偏移后的坐标去SCREEN_TEXTURE里"偷"颜色 -
偏移量必须随空间位置变化(每个像素偏移不同),否则只是平移
-
噪声比正弦波更适合模拟自然扭曲
6. 实战 3:放大镜效果
热浪是"全屏偏移"——整个画面被统一扭曲。放大镜是一个局部效果:只有圆形镜头内的区域被放大,镜头外的画面保持不变。这引入了一个新的问题:如何让一个非全屏的小节点(放大镜 ColorRect),从它所在的屏幕位置读取对应的 SCREEN_TEXTURE 像素?
这个问题的答案是本章最重要的坐标换算课。它一次性串起 SCREEN_UV 和 SCREEN_PIXEL_SIZE 的全部关系。
6.1 问题分析:小节点的 SCREEN_UV 不等于屏幕坐标
回顾第 2 节:对一个全屏 ColorRect,UV == SCREEN_UV。但对一个漂浮在屏幕上的放大镜,情况很不同:
UV范围仍是(0,0)到(1,1),覆盖放大镜的四个角SCREEN_UV返回的是当前片元在屏幕上的位置——对于这个 128×128 的Sprite2D,它的SCREEN_UV值只覆盖屏幕上一小块区域
如果你直接写 texture(screen_texture, SCREEN_UV),你读到的是放大镜 ColorRect 正下方那块屏幕画面——这很好,正是我们需要的"被放大的内容"。但问题来了:你要放大这个画面,意味着你需要从更中心的 UV 位置采样——你需要知道放大镜中心在屏幕 UV 空间中的位置。
这就引出了我们之前学习过的坐标计算法。
6.2 坐标系换算的核心逻辑
放大镜效果需要一个关键的坐标信息:放大镜节点在屏幕上的中心位置,用 UV 坐标表示。这个信息通过两步得到:
- 顶点着色器中:
MODEL_MATRIX * CANVAS_MATRIX * vec4(local_center, 0.0, 1.0)将节点原点位置(local_center = vec2(0.0))经过模型变换和画布变换,得到该原点在屏幕上的像素坐标。这个值通过varying变量传给片段着色器。 - 片段着色器中:
node_center_pos * SCREEN_PIXEL_SIZE将像素坐标转换为屏幕 UV 坐标——这就是lens_center,放大镜中心在屏幕 UV 空间中的位置。
有了 lens_center,就可以使用经典的 UV 中心缩放公式:
含义:以 lens_center 为中心,把 SCREEN_UV 向内"拉"——zoom_strength 越大,采样坐标离中心越近,画面被放大越多。
6.3 第 1 步:在屏幕上画一个圆
首先创建一个 128×128 大小的 Sprite2D节点,然后在其下挂一个 shader。片段着色器中,代码首先根据 UV 坐标计算出当前像素相对于纹理中心(0.5, 0.5)的距离 dist,并利用 smoothstep 函数配合 lens_radius(透镜半径)和 edge_softness(边缘柔和度)参数生成一个边缘平滑的圆形遮罩 mask。
shader_type canvas_item;
uniform sampler2D screen_texture : hint_screen_texture;
uniform float lens_radius : hint_range(0.0, 1.0) = 0.4;
uniform float edge_softness : hint_range(0.0, 0.2) = 0.05;
void fragment() {
vec4 color = texture(screen_texture, SCREEN_UV);
// 计算当前像素到中心 (0.5, 0.5) 的偏移量和距离
vec2 center_offset = UV - 0.5;
float dist = length(center_offset);
// 生成平滑的圆形遮罩
float mask = 1.0 - smoothstep(lens_radius - edge_softness, lens_radius, dist);
COLOR = vec4(mask);
}
你会看到一个圆形的"窗口",圆的内部显示白色,圆的外部是透明,显示出底层的图片。在这里color是底层图片信息,但代码中只是读取并未用到。
6.4 第 2 步:在圆内放大画面
- 在顶点着色器(
vertex)中,将节点原点坐标local_center = vec2(0.0)通过MODEL_MATRIX * CANVAS_MATRIX变换为屏幕像素坐标,用varying变量node_center_pos传给片段着色器。 node_center_pos * SCREEN_PIXEL_SIZE将像素坐标转换成 UV 坐标,得到放大镜中心在屏幕中的位置,赋值给lens_center;- 核心的放大算法通过公式
lens_center + (SCREEN_UV - lens_center) * (1.0 / zoom_strength)——即经典的以 UV 为中心的缩放:(当前坐标 - 中心点) * 缩放系数 + 中心点——计算出经过缩放后的屏幕采样坐标zoomed_sample_uv。 - 最后,代码从
screen_texture(屏幕纹理)中采样该偏移坐标的颜色,将圆形遮罩mask赋值给color.a以实现圆形裁剪,最终输出到COLOR。无论放大镜在屏幕的什么位置,镜头内的画面都正确地反映了它下方被放大的屏幕内容。
shader_type canvas_item;
uniform sampler2D screen_texture : hint_screen_texture;
uniform float lens_radius : hint_range(0.0, 1.0) = 0.4;
uniform float edge_softness : hint_range(0.0, 0.2) = 0.05;
uniform float zoom_strength : hint_range(1.0, 4.0) = 2.0;
varying vec2 node_center_pos;
void vertex() {
// 将当前中心点转换后坐标传递给片段着色器
vec2 local_center = vec2(0.0);
node_center_pos = (MODEL_MATRIX * CANVAS_MATRIX * vec4(local_center, 0.0, 1.0)).xy;
}
void fragment() {
vec2 center_offset = UV - 0.5;
float dist = length(center_offset);
float mask = 1.0 - smoothstep(lens_radius - edge_softness, lens_radius, dist);
// 核心放大算法:计算放大后的屏幕 UV 坐标
vec2 lens_center = node_center_pos * SCREEN_PIXEL_SIZE;
// 使用乘法进行缩放:(当前坐标 - 中心点) * 缩放系数 + 中心点
vec2 zoomed_sample_uv = lens_center + (SCREEN_UV - lens_center) * (1.0/zoom_strength);
vec4 color = texture(screen_texture, zoomed_sample_uv);
color.a = mask;
COLOR = color;
}
6.5 第 3 步:柔化边缘 + 混合未放大的背景
用 mix 在镜头边缘做平滑过渡,mask 从 1 渐变到 0,采样坐标从"全放大"渐变到"不放大",边缘不再生硬
void fragment() {
vec2 center_offset = UV - 0.5;
float dist = length(center_offset);
float mask = 1.0 - smoothstep(lens_radius - edge_softness, lens_radius, dist);
vec2 lens_center = node_center_pos * SCREEN_PIXEL_SIZE;
vec2 zoomed_sample_uv = lens_center + (SCREEN_UV - lens_center) * (1.0/zoom_strength);
// 混合原始的屏幕 UV 和放大后的 UV
vec2 sample_uv = mix(SCREEN_UV, zoomed_sample_uv, mask);
vec4 color = texture(screen_texture, sample_uv);
color.a = mask;
COLOR = color;
}
完整代码参见:magnifier.gdshader
可以再增加一个配套 GDScript(挂载到放大镜 ColorRect 上)。然后运行游戏场景,可以看到放大镜会跟随鼠标移动,所在图片区域的细节被放大:

本章小结
本章三个实战,实际上是屏幕空间技术的三个递进层次:
| 层次 | 实战 | 技术模式 | 适用范围 |
|---|---|---|---|
| 全局采样 + 后处理 | 菜单背景模糊 | textureLod() 强制低分辨率采样 |
全屏后处理(全屏 ColorRect) |
| 全局偏移 | 热浪扭曲 | 噪声偏移 SCREEN_UV → 重采样 |
全屏扭曲效果 |
| 局部缩放 + 坐标换算 | 放大镜 | 顶点着色器计算节点中心 → * SCREEN_PIXEL_SIZE 转为屏幕 UV |
任意位置的局部后处理 |
放大镜的坐标换算逻辑是整个"屏幕空间技术"工具箱中最被低估的内容。一旦你理解了 node_center_pos * SCREEN_PIXEL_SIZE 这条换算线——"节点像素坐标 → 屏幕 UV 坐标"——你就可以写出任意位置的局部后处理效果:局部模糊、局部灰度、局部色彩反转。它们都是同一个"用节点中心定位屏幕 UV"的变体。
记忆锚点:
SCREEN_TEXTURE是"看到什么",SCREEN_UV是"在哪里看到",SCREEN_PIXEL_SIZE是"如何在像素和 UV 之间换算"(像素坐标 ×SCREEN_PIXEL_SIZE= UV 坐标)。
章末练习
-
修改热浪扭曲的偏移强度:将
distortion_amount从0.03逐步调到0.08,观察效果。在哪个值附近画面开始失去"热浪"的感觉、变成了不可辨认的碎片?思考为什么"少即是多"。 -
给热浪扭曲添加深度渐变:用
SCREEN_UV.y计算一个深度权重——屏幕上方(y小)扭曲幅度小,屏幕下方(y大)扭曲幅度大。将distortion_amount乘以这个权重。这模拟了"热量上强下弱"的真实物理。 -
反向放大镜:在放大镜 shader 中,将
zoom_strength设为0.5(小于 1.0)。观察镜头内的画面是被放大还是缩小?