Unity Shader 齐次坐标与透视除法理解 SV_POSITION 的 w 分量

从三维世界坐标到屏幕像素的旅程中,w 分量是透视投影的核心秘密。 本文深入拆解齐次坐标系、裁剪空间、透视除法,以及 w 在插值中扮演的不可替代角色。

为什么需要齐次坐标?

在三维渲染中,我们需要对顶点执行平移、旋转、缩放和透视投影 等变换。 旋转缩放变换可以用 3×3 矩阵统一表示,但平移无法用 3×3 矩阵乘法来完成------ 这就是齐次坐标(Homogeneous Coordinates)诞生的根本原因。

问题:平移矩阵

三维向量加平移 = 向量加法,无法写成 3×3 矩阵乘法形式,所有变换无法统一。

解决:升维至 4D

引入第四个分量 w,将点表示为 (x, y, z, w),平移可用 4×4 矩阵乘法统一处理。

奖励:透视投影

w 分量还能自然地编码"除法"操作,完美支持透视变换------这是最大的惊喜。

核心洞见

齐次坐标不是一个"技巧",它是射影几何(Projective Geometry)的自然语言。透视相机的成像原理在射影空间中能被优雅地用矩阵乘法表达。

欧式空间 vs 齐次/射影空间

特性 欧式空间 (3D) 齐次空间 (4D)
点表示 (x, y, z) (x, y, z, w),通常 w=1
方向向量 (dx, dy, dz) (dx, dy, dz, 0),w=0 表示无穷远
平移变换 向量加法,无法用矩阵乘 统一用 4×4 矩阵乘法表示
透视投影 需要特殊除法处理 矩阵乘法自然生成 w,除法统一在最后执行

齐次坐标的数学本质

在齐次坐标中,三维空间中的点 (X, Y, Z) 用四维向量 (x, y, z, w) 表示, 两者之间的转换关系为:

这意味着 (2, 4, 6, 1)(4, 8, 12, 2)(1, 2, 3, 0.5) 在三维空间中表示同一个点 (2, 4, 6)------ 因为它们都满足 X=x/w, Y=y/w, Z=z/w。

⚠️

w = 0 的特殊含义

当 w = 0 时,齐次坐标表示无穷远处的方向向量,而非一个具体位置。 平行光的方向、天空盒顶点通常使用 w=0。对 w=0 执行透视除法会产生除零错误,GPU 驱动层会特殊处理这种情况。

透视投影矩阵与裁剪空间

渲染管线中,顶点着色器将模型坐标变换到裁剪空间(Clip Space)。 这个过程通过 MVP 矩阵(Model × View × Projection)完成:

将观察空间顶点 (Xv, Yv, Zv, 1) 乘以此矩阵后, 输出的裁剪坐标 (Xc, Yc, Zc, Wc) 中,w 分量等于观察空间的 z 值

关键结论

透视投影矩阵将"观察空间深度 Zview"编码进了裁剪坐标的 w 分量。 这个 w 就是后续透视除法的除数,也是透视效果的核心来源。

cs 复制代码
// 顶点着色器输出结构
struct VSOutput {
    float4 position : SV_POSITION;  // 裁剪坐标 (Xc, Yc, Zc, Wc)
    float2 uv       : TEXCOORD0;
};
VSOutput main(float3 posLocal : POSITION) {
    VSOutput o;
    // MVP 变换:将顶点变换到裁剪空间
    o.position = mul(mvpMatrix, float4(posLocal, 1.0));
    // o.position.w ≈ 观察空间的 Z 深度!
    return o;
}

透视除法(Perspective Divide)

顶点着色器输出裁剪坐标后,GPU 的固定管线硬件会自动执行透视除法, 将裁剪坐标转换为 NDC(Normalized Device Coordinates,标准化设备坐标):

这就是透视效果的物理本质:距离摄像机越远(Z 越大),除数越大,坐标越小,物体越小。 这正是近大远小的透视规律。

透视除法由谁执行?

透视除法是由 GPU 硬件固定管线自动完成的,发生在顶点着色器与光栅化之间, 不需要也不应该在着色器代码中手动执行。这也意味着:

1

顶点着色器输出裁剪空间坐标 SV_POSITION(含 w 分量)

2

GPU 用裁剪坐标进行视锥体裁剪(判断 -w ≤ x,y,z ≤ w)

3

GPU 执行透视除法 → 得到 NDC 坐标([-1,1] 范围)

4

NDC 经视口变换映射到屏幕像素坐标

SV_POSITION 的 w 分量详解

在 HLSL 中,SV_POSITION 语义标记顶点的裁剪空间坐标。 它的四个分量意义如下:

sv.x / sv.y

裁剪坐标 x、y。透视除法后变成 NDC xy,再映射到屏幕坐标。

sv.z

裁剪坐标 z。透视除法后变成 NDC 深度,写入深度缓冲区。范围 [0,1](DX)或 [-1,1](GL)。

sv.w ← 关键

裁剪坐标 w,即观察空间深度 Zview。是透视除法的除数,也存储用于透视正确插值。

在像素着色器中读取 w

一个关键细节:像素着色器中读到的 SV_POSITION.w 并不是裁剪空间的原始 w, 而是经过硬件处理后的 1 / Wclip(即深度的倒数)。 这是为了方便 GPU 进行透视正确插值而做的优化。

🔴

PS 阶段 SV_POSITION.w = 1 / Wclip

顶点着色器输出时 position.w = W_clip = Z_view(通常 > 1)。

但像素着色器接收到的 SV_POSITION.w = 1.0 / W_clip(通常 < 1)。

如果需要在 PS 中重建线性深度,记住这个反转!

cs 复制代码
float4 PS_Main(float4 sv_pos : SV_POSITION) : SV_TARGET
{
    // sv_pos.xy = 屏幕像素坐标(光栅化后,已执行透视除法)
    // sv_pos.z  = NDC 深度 (已写入深度缓冲)
    // sv_pos.w  = 1.0 / W_clip  ← 注意!这是倒数!
    // 如需还原线性 view-space depth:
    float linearZ = 1.0 / sv_pos.w;   // = W_clip = Z_view
    // 可视化深度(归一化到 [0,1])
    float depthVis = saturate(linearZ / farPlane);
    return float4(depthVis, depthVis, depthVis, 1.0);
}

w 分量在插值中的作用

光栅化阶段,GPU 对三角形内部的每个像素插值 顶点属性(UV、颜色、法线等)。 朴素的线性插值会产生严重的透视失真------透视正确插值(Perspective-Correct Interpolation) 正是依赖 w 分量来修正这个问题。

问题:线性插值的透视失真

设三角形两顶点 A、B 在屏幕上的位置为 50% 处,但 A 距摄像机很近(w=2),B 很远(w=10)。 如果对顶点属性(如 UV)做屏幕空间线性插值,结果是几何上的中点,而非三维空间的中点------ 这会导致贴图拉伸变形。

解决方案:透视正确插值公式

GPU 使用以下公式插值属性 φ(如 UV 坐标):

其中 t 是屏幕空间的线性插值参数。这个公式等价于先对 φ/w 线性插值, 再除以对 1/w 的线性插值------这就是为什么 GPU 在光栅化时存储 1/w(即 PS 阶段 SV_POSITION.w 的值)。

🔬

nointerpolation vs 默认插值

HLSL 中对顶点输出结构体的成员默认执行透视正确插值。 如果使用 nointerpolation 关键字,则采用最近顶点的值 ,完全跳过插值。 linear 关键字则强制使用屏幕空间线性插值(透视不正确,但性能更高)。

cs 复制代码
struct PSInput {
    float4 pos    : SV_POSITION;          // 总是透视正确,w = 1/W_clip
    float2 uv     : TEXCOORD0;            // 默认:透视正确插值
    linear float3 color  : COLOR;        // linear:屏幕空间线性插值
    nointerpolation uint id : TEXCOORD1; // 不插值,取最近顶点
    centroid float2 uv2 : TEXCOORD2;   // centroid:多重采样抗锯齿用
};

常见陷阱与调试技巧

陷阱1:在 VS 中手动除以 w

不要在顶点着色器中执行 position /= position.w------ 这会破坏透视除法的语义,导致深度缓冲写入错误、裁剪错误,以及透视插值完全失效。 透视除法由硬件自动完成,务必保留原始 w。

陷阱2:混淆 VS 和 PS 中 SV_POSITION.w 的含义

顶点着色器输出的 SV_POSITION.w = W_clip = Z_view(大值)。 像素着色器接收的 SV_POSITION.w = 1.0 / W_clip(小值)。 两者相差一个倒数,混淆后重建深度时会得到完全错误的结果。

⚠️

陷阱3:w 接近0时的精度问题

当顶点非常靠近摄像机(Z_view → 0)时,w 也趋近于0,透视除法结果趋向无穷大。 确保 near plane 设置合理(不要太小),避免 Z-fighting 和数值溢出。

调试技巧

1

可视化 w 值: 在 PS 中输出 1.0 / sv_pos.w 并归一化, 得到线性深度图,快速验证深度是否正确。

2

验证 NDC: 确认顶点的 position.xyz / position.w 都在 [-1,1](OpenGL)或 [0,1](DirectX z)范围内,超出范围的顶点会被裁剪。

3

Renderdoc 抓帧: 在 Vertex Output 面板直接查看每个顶点的 SV_POSITION 四分量,对比期望值进行调试。

4

检查矩阵行/列主序: HLSL 默认列向量右乘(mul(M, v)), 确认传入 GPU 的矩阵是否已经做了转置,错误的主序是 w 异常的常见原因。

cs 复制代码
// 调试 pass:将线性深度可视化为灰度图
float4 DebugDepth_PS(float4 svpos : SV_POSITION) : SV_TARGET
{
    // PS 中 SV_POSITION.w == 1 / W_clip
    float Wclip    = 1.0 / svpos.w;          // = Z_view
    float linearDepth = saturate(Wclip / g_FarPlane);
    return float4(linearDepth.xxx, 1.0);      // 灰度输出
}
// 注意:svpos.z 是非线性深度(NDC depth)
// 距离 near plane 很远的地方变化极慢(精度浪费)
// 线性深度用 1/w 重建更直观

完整代码示例

以下是一个完整的 HLSL Shader 示例,演示 MVP 变换、SV_POSITION 输出, 以及在像素着色器中正确利用 w 分量重建线性深度:

cs 复制代码
// ── Constant Buffer ──────────────────────────────────
cbuffer SceneConstants : register(b0)
{
    float4x4 g_MVP;           // Model * View * Projection
    float    g_NearPlane;
    float    g_FarPlane;
};
// ── I/O Structures ───────────────────────────────────
struct VSInput
{
    float3 posModel : POSITION;   // 模型空间位置
    float2 uv       : TEXCOORD0;  // UV 坐标
    float3 normal   : NORMAL;     // 法线
};
struct PSInput
{
    float4 posClip  : SV_POSITION;  // 裁剪坐标(VS输出),像素中变为1/w
    float2 uv       : TEXCOORD0;    // 透视正确插值的 UV
    float3 worldPos : TEXCOORD1;    // 世界坐标(用于光照)
};
// ── Vertex Shader ────────────────────────────────────
PSInput VS_Main(VSInput IN)
{
    PSInput OUT;
    // MVP 变换:float4(pos,1) * M * V * P
    OUT.posClip = mul(g_MVP, float4(IN.posModel, 1.0f));
    // OUT.posClip.w 此时 == Z_view (观察空间深度)
    // 千万不要 OUT.posClip /= OUT.posClip.w !
    OUT.uv       = IN.uv;
    OUT.worldPos = IN.posModel;  // 简化:假设 Model == Identity
    return OUT;
}
// ── Pixel Shader ─────────────────────────────────────
float4 PS_Main(PSInput IN) : SV_TARGET
{
    // IN.posClip.w 在 PS 中 == 1 / W_clip (硬件已自动转换)
    float viewZ = 1.0f / IN.posClip.w;  // 还原观察空间深度
    float depth01 = saturate((viewZ - g_NearPlane)
                           / (g_FarPlane - g_NearPlane));
    // IN.uv 已经是透视正确插值的结果,直接采样
    float4 albedo = g_Texture.Sample(g_Sampler, IN.uv);
    return albedo;
}

核心要点总结

齐次坐标

4D 向量 (x,y,z,w) 表示 3D 点 (x/w, y/w, z/w),统一所有仿射变换为矩阵乘法。

投影矩阵输出

透视投影矩阵将 Z_view 写入 clip.w,这是透视效果的数学来源。

透视除法

硬件自动执行,÷w 得到 NDC。不要在 shader 中手动执行!

PS 中的 w

像素着色器 SV_POSITION.w = 1/W_clip,需取倒数才能得到线性深度。

透视正确插值

GPU 利用 1/w 对属性做透视正确插值,避免贴图在透视下变形。

w=0 的含义

w=0 表示无穷远方向向量(如平行光方向),不可执行透视除法。

相关推荐
mxwin1 天前
unity shader中 ddx ddy是什么
unity·游戏引擎·shader
郝学胜-神的一滴1 天前
[简化版 GAMES 101] 计算机图形学 08:三角形光栅化上
c++·unity·游戏引擎·godot·图形渲染·opengl·unreal
nnsix2 天前
Unity ILRuntime 笔记
unity·游戏引擎
nnsix2 天前
Unity API 兼容的 .NET Standard 2.1 和 .NET Framework 区别
unity·游戏引擎·.net
mxwin2 天前
Unity Shader 制作半透明物体 使用多Pass提前写入深度的方式 避免穿模
unity·游戏引擎
nnsix2 天前
Unity HybridCLR 笔记
笔记·unity·游戏引擎
nnsix2 天前
Unity Addressables 笔记
unity·游戏引擎
RReality2 天前
【Unity Shader URP】视差贴图 实战教程
ui·平面·unity·游戏引擎·图形渲染·贴图
小清兔2 天前
Addressable的设置打包流程
笔记·游戏·unity·c#
3D霸霸2 天前
Sourcetree 拉取新工程
数据仓库·unity