PAYATHON 2026

Godot 中同一投影点的采样深度与计算深度为何相差很大?

数据小陈

优先检查两次计算是否使用了同一坐标空间,以及计算出的 UV 是否指向深度图中的同一位置。两段代码使用了相同的深度公式,但传给 flashlight_projection 的坐标含义不同:一端是网格局部坐标,另一端是相对 2D 相机的位置。即使地面高度为零,这种差异也可能导致整片区域出现深度偏差。

现有材料没有给出 flashlight_projection 的构造代码、地形变换和 viewport 设置,因此还不能确定唯一根因。可以按以下顺序排查。

1. 先统一坐标空间

深度 shader 中的这行代码没有得到世界坐标:

v_world_position = VERTEX;

在当前使用的默认 spatial shader 变换方式下,vertex() 中的 VERTEX 是模型局部坐标,变量名不会改变它的坐标空间。要转换到世界坐标,需要使用 MODEL_MATRIX。相关定义见 Godot 4.6 Spatial shaders 文档。

覆盖层传入的坐标则是:

vec2 vector_from_camera = position - camera_position;

代入 get_global_from_UV() 后,得到:

vector_from_camera = (SCREEN_UV - 0.5) * viewport_size / zoom;

这里的 camera_position 被抵消了。除非地形和光源矩阵也使用同一个随 2D 相机移动的坐标原点,否则两端不会表示同一个场景点。

建议明确采用以下变换关系:

网格局部坐标 → MODEL_MATRIX → 世界坐标
世界坐标 → 光源 VIEW_MATRIX → 光源视空间
光源视空间 → 光源 PROJECTION_MATRIX → clip space

也就是:

clip = P_light * V_light * world_position

如果 flashlight_projection 只是投影矩阵 P_light,输入必须已经处于光源视空间;如果它包含 P_light * V_light,输入必须是世界坐标。先检查传入 uniform 的实际内容,避免遗漏或重复执行 view 变换。

2. 让编码矩阵与实际渲染相机一致

第一段 shader 没有写入 POSITION,因此网格实际落在哪个像素,仍由 Godot 的正常顶点变换决定。片元中自行计算的 flashlight_projection 只影响写入颜色的数值,不会改变片元所在的位置。

实际渲染使用的相机与自定义矩阵必须匹配,包括位置、朝向、投影方式、宽高比及裁剪范围。否则,即使颜色中的深度计算正确,覆盖层也可能从另一个位置读取它。

如果统一使用世界坐标,可以将深度 shader 的关键部分改为:

uniform mat4 light_view_projection;
varying vec3 v_world_position;

void vertex() {
    VERTEX.y = height_scale * texture(heightmap, UV).r;
    v_world_position = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;
}

void fragment() {
    vec4 clip_space = light_view_projection
        * vec4(v_world_position, 1.0);
    float encoded_depth = -(clip_space.z / clip_space.w) * 0.5 + 0.5;
    ALBEDO = vec3(encoded_depth);
}

这里的 light_view_projection 必须是与深度 viewport 实际渲染相机对应的 P_light * V_light。示例保留了原来的深度编码,便于先检查坐标变换。

覆盖层也要构造同一世界坐标系中的点。只有在 2D 的 (x, y) 与 3D 的 (x, z) 原点、方向和单位完全一致时,零高度地面才能直接写成:

vec2 position = get_global_from_UV(SCREEN_UV);
vec3 world_position = vec3(position.x, 0.0, position.y);
vec4 clip_space = light_view_projection * vec4(world_position, 1.0);

如果地形有平移、缩放或轴向转换,需要在这里补上对应映射。对于非零高度的接收点,也不能始终把高度写成 0.0。

3. 单独验证 UV,避免把采样位置错误当成深度错误

原代码无条件翻转了纹理 Y 坐标:

flashlight_depth_texture_uv.y = 1.0 - flashlight_depth_texture_uv.y;

是否需要这一步,应根据实际纹理方向确认。可以在地形不同角落放置可辨识的标记,检查投影后的 UV 是否落在对应位置。仅凭深度灰度图,通常不容易发现上下颠倒。

另外,设置 COLOR.a = 0.0 不会终止后续执行。原代码在 clip_space.w 无效时仍会继续除法,在 UV 越界后仍会采样。对于不需要绘制的片元,可以提前 discard:

if (clip_space.w <= 1e-5) {
    discard;
}

vec2 depth_uv = clip_space.xy / clip_space.w * 0.5 + 0.5;

// 仅在验证纹理方向后保留这行。
depth_uv.y = 1.0 - depth_uv.y;

if (any(lessThan(depth_uv, vec2(0.0))) ||
    any(greaterThan(depth_uv, vec2(1.0)))) {
    discard;
}

除了检查 XY 范围,还应按实际投影约定排除 near/far 范围之外的点。

原来的屏幕坐标反推公式也有适用条件:它假设视口中心对应 camera_position,并且没有尚未计入的相机旋转、偏移或额外 canvas 变换。如果这些条件不成立,应根据实际 canvas 变换重建位置。

4. 确认颜色纹理保留了写入的深度值

当前代码通过 ALBEDO 存储深度,因此 flashlight_depth 实际上是承载深度编码的颜色纹理。写入和读取时使用相同公式,并不能证明中间的颜色输出与采样过程没有改变数值。

先把深度 shader 的输出临时改成固定值,例如:

ALBEDO = vec3(0.25);

再分别测试 0.5 和 0.75,检查覆盖层采样得到的 .r。应检查数值,不要只看显示出来的灰度。

如果固定值无法按预期读回,应先检查颜色空间转换、tonemapping、后处理、混合、抗锯齿和纹理格式精度。unshaded 不能证明整条数值传输链路保持了原值。

还需要确认 heightmap 是否按预期解释为数值数据。原声明带有 source_color;高度数据是否应经过颜色空间转换,要根据纹理的实际编码决定,不能仅因为它表现为灰度就认定设置正确。

5. 深度编码要一致,遮挡比较要有方向

原来的编码是:

float encoded_depth = -(clip_space.z / clip_space.w) * 0.5 + 0.5;

不要仅根据 depth_01 这个名称认定它覆盖了完整的 [0, 1] 范围。Godot 的 NDC Z 范围与渲染器有关,Forward+、Mobile 与 Compatibility 的约定存在差异,还需要考虑 reverse Z。具体说明见 Godot 4.6 深度纹理文档。

如果两端确实对同一个点应用同一个矩阵和同一个编码,数值应当一致。仅修改这条公式中的负号或 0.5,不足以解决坐标或采样位置不一致的问题。

这里的数值是投影深度编码,并非到光源的欧氏距离。用于比较时不一定需要线性化,但两端的表示必须一致。

下面的绝对差适合显示误差:

abs(sampled_depth - point_depth)

它不能直接表达遮挡关系。阴影映射要判断目标点是否位于当前光线方向上最近表面的后方。

如果编码值随距离增大而增大:

bool occluded = point_depth > sampled_depth + bias;

如果编码值随距离增大而减小:

bool occluded = point_depth < sampled_depth - bias;

应使用已知远近的两个点确认比较方向。bias 用来容纳采样和精度误差,不能掩盖整片区域的坐标偏移。没有几何体写入的背景像素,也需要使用与编码方向一致的清除值,或通过单独的有效性标记识别。

6. 建议的排查顺序

  1. 使用零高度平面,明确 2D 坐标与 3D 地形坐标的映射。
  2. 检查 flashlight_projection 的构造,确认两端输入空间以及实际渲染相机是否一致。
  3. 用位置标记验证投影 UV,确认是否需要翻转 Y。
  4. 用固定灰度值检查颜色写入和采样是否保持数值。
  5. 在匹配位置上查看未经 abs() 和 clamp() 处理的深度差,保留误差方向和大小。
  6. 最后加入非零高度、真实遮挡物、单向比较和适当的 bias。

最值得先检查的是 v_world_position = VERTEX 与 position - camera_position 这两处:它们目前没有保证两端使用同一坐标空间。

备注:内容仅供参考。