跳转至

第 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 里采样

原始画面 → 偏移坐标 → 重新采样 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() 采样:

uniform sampler2D screen_texture : hint_screen_texture;

这行代码告诉 Godot:"请把当前帧的渲染结果塞进这个 sampler2D,我来读取它。" 没有这个 hint,你的 uniform 只是一个空纹理。

注意uniform 变量名可以任意取(如 screenscreen_tex),但全书统一使用 screen_texture

2.2 SCREEN_UV:屏幕上的归一化坐标

它是什么? SCREEN_UV 是当前像素在整个屏幕上的归一化坐标,范围 (0, 0)(1, 1)(0, 0) 是屏幕左上角,(1, 1) 是右下角,(0.5, 0.5) 是正中心。

为什么需要它? 当你有一个全屏覆盖的 ColorRect 节点时,它的 UVSCREEN_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 就是这两套坐标系之间的换算尺:

像素坐标 → UV 坐标:  pixel_coord * SCREEN_PIXEL_SIZE
UV 坐标  → 像素坐标: uv / SCREEN_PIXEL_SIZE

2.4 FRAGCOORD:当前片元的屏幕像素坐标

它是什么? FRAGCOORD 是一个 vec4,其中 xy 分量是当前片元在屏幕上的像素整数坐标(原点在左上角),z 分量是深度值,w 分量是 1.0 / 片元 W 分量

为什么需要它? 当你需要做精确的像素级操作时——比如像素化、马赛克、扫描线(隔行变色)你需要在像素整数空间做取整、取模等运算。FRAGCOORD.xy 就是这套整数空间的入口。

FRAGCOORDSCREEN_UV 描述的是同一个东西,但用的坐标系不同:

FRAGCOORD.xy = SCREEN_UV / SCREEN_PIXEL_SIZE
SCREEN_UV    = FRAGCOORD.xy * SCREEN_PIXEL_SIZE

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

关键点回顾

  1. SCREEN_UV 提供采样坐标 → 你偏移它 → 用偏移后的坐标去 SCREEN_TEXTURE 里"偷"颜色

  2. 偏移量必须随空间位置变化(每个像素偏移不同),否则只是平移

  3. 噪声比正弦波更适合模拟自然扭曲


6. 实战 3:放大镜效果

热浪是"全屏偏移"——整个画面被统一扭曲。放大镜是一个局部效果:只有圆形镜头内的区域被放大,镜头外的画面保持不变。这引入了一个新的问题:如何让一个非全屏的小节点(放大镜 ColorRect),从它所在的屏幕位置读取对应的 SCREEN_TEXTURE 像素?

这个问题的答案是本章最重要的坐标换算课。它一次性串起 SCREEN_UVSCREEN_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 坐标表示。这个信息通过两步得到:

  1. 顶点着色器中MODEL_MATRIX * CANVAS_MATRIX * vec4(local_center, 0.0, 1.0) 将节点原点位置(local_center = vec2(0.0))经过模型变换和画布变换,得到该原点在屏幕上的像素坐标。这个值通过 varying 变量传给片段着色器。
  2. 片段着色器中node_center_pos * SCREEN_PIXEL_SIZE 将像素坐标转换为屏幕 UV 坐标——这就是 lens_center,放大镜中心在屏幕 UV 空间中的位置。

有了 lens_center,就可以使用经典的 UV 中心缩放公式:

(SCREEN_UV - lens_center) * (1.0 / zoom_strength) + lens_center

含义:以 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 上)。然后运行游戏场景,可以看到放大镜会跟随鼠标移动,所在图片区域的细节被放大:

extends ColorRect

func _process(_delta):
    global_position = get_global_mouse_position()

放大镜效果


本章小结

本章三个实战,实际上是屏幕空间技术的三个递进层次:

层次 实战 技术模式 适用范围
全局采样 + 后处理 菜单背景模糊 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 坐标)。


章末练习

  1. 修改热浪扭曲的偏移强度:将 distortion_amount0.03 逐步调到 0.08,观察效果。在哪个值附近画面开始失去"热浪"的感觉、变成了不可辨认的碎片?思考为什么"少即是多"。

  2. 给热浪扭曲添加深度渐变:用 SCREEN_UV.y 计算一个深度权重——屏幕上方(y 小)扭曲幅度小,屏幕下方(y 大)扭曲幅度大。将 distortion_amount 乘以这个权重。这模拟了"热量上强下弱"的真实物理。

  3. 反向放大镜:在放大镜 shader 中,将 zoom_strength 设为 0.5(小于 1.0)。观察镜头内的画面是被放大还是缩小?