从三维世界坐标到屏幕像素的旅程中,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 就是后续透视除法的除数,也是透视效果的核心来源。

// 顶点着色器输出结构
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 中重建线性深度,记住这个反转!

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 关键字则强制使用屏幕空间线性插值(透视不正确,但性能更高)。

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 异常的常见原因。

// 调试 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 分量重建线性深度:

// ── 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 表示无穷远方向向量(如平行光方向),不可执行透视除法。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐