PAYATHON 2026

CPU raytracer 应如何处理负纹理坐标,才能与 DirectX 12 一致?

数据小许

结论:先确认 tSampler,再修正 CPU 端的寻址逻辑

负纹理坐标是合法的,最终采样的位置取决于纹理采样器的寻址模式。

CPU 代码中最直接的问题是对插值后的 UV 调用了 std::abs。如果 DirectX 12 的 tSampler 使用 D3D12_TEXTURE_ADDRESS_MODE_WRAP,这种处理就与 GPU 的行为不一致:-0.2 在 Wrap 下应映射到 0.8,取绝对值却会得到 0.2。

原始材料没有给出 tSampler 的创建或绑定配置,因此无法确定 GPU 实际使用的是 Wrap、Mirror 还是其他模式。当前 CPU 逻辑不能作为通用的纹理寻址实现。

为什么 abs 和 modf 会产生差异

Wrap 的数学定义是:

wrap(x) = x - floor(x)

正负坐标都会按周期重复:

原始坐标Wrap 结果当前 CPU 代码结果
-1.20.8约 0.2
-0.20.8约 0.2
0.20.2约 0.2
1.00.01.0
1.20.2约 0.2

还需要注意:

  • std::modf 按向零截断的方式分离整数部分和小数部分,负数的小数部分仍可能为负。因此,只删除 std::abs 并继续直接使用 std::modf,仍不能正确实现 Wrap。
  • 原代码只处理大于 1.0f 的值,恰好等于 1.0f 时不会归一到 0.0f。在周期寻址中,两者对应同一个纹理周期位置。

abs 加上取小数部分也不等于完整的 Mirror 寻址。Mirror 要求相邻区间交替翻转,当前代码没有处理这种交替关系。

第一步:检查 GPU 实际使用的寻址模式

找到 tSampler 对应的 sampler 配置,检查 D3D12_SAMPLER_DESC 或静态 sampler 描述中的 AddressU 和 AddressV。

CPU 必须分别匹配两个方向的设置:

  • D3D12_TEXTURE_ADDRESS_MODE_WRAP:按周期重复纹理。
  • D3D12_TEXTURE_ADDRESS_MODE_MIRROR:相邻周期交替镜像。
  • D3D12_TEXTURE_ADDRESS_MODE_CLAMP:超出范围时使用边缘。
  • D3D12_TEXTURE_ADDRESS_MODE_BORDER:超出范围时使用边框颜色。

不要根据像素着色器中的 Sample(tSampler, input.texCoord) 推断寻址模式,这行调用没有展示 sampler 的具体配置。

第二步:如果 GPU 使用 Wrap,替换 CPU 的坐标处理

如果输入是有限浮点数,且 GPU 的 U/V 均使用 Wrap,可以先把现有函数改为:

#include <cmath>

inline float wrap01(float value)
{
    return value - std::floor(value);
}

inline Math::Vec2 BaseMaterial::interpolateTexCoordinates(
    const Math::Vec2& t1,
    const Math::Vec2& t2,
    const Math::Vec2& t3,
    const Math::Vec3& coefs) const
{
    Math::Vec2 texCoord =
        (t1 * coefs.x) +
        (t2 * coefs.y) +
        (t3 * coefs.z);

    texCoord.s = wrap01(texCoord.s);
    texCoord.t = wrap01(texCoord.t);

    return texCoord;
}

这样可以修正当前代码中的负坐标映射错误。不过,这段代码只处理坐标的周期转换,尚不能保证 CPU 与 GPU 的过滤结果完全相同。

如果需要支持不同材质或不同寻址模式,更适合让 interpolateTexCoordinates 只返回原始插值结果,把 Wrap、Mirror 等逻辑放进纹理采样函数。这样每张纹理都能按各自的 sampler 设置采样,也能保留后续计算 LOD 所需的连续 UV。

第三步:先插值,再执行寻址

应保留当前代码中先插值的顺序:

uv = t1 * coefs.x + t2 * coefs.y + t3 * coefs.z

不要为了处理负数而提前对每个顶点的 UV 取绝对值或执行 Wrap,这些操作会改变三角形内部的插值路径。

例如,一条边的两个端点坐标为 0.9 和 1.1。原始坐标在中点插值得到 1.0,随后 Wrap 得到 0.0。如果先把两个端点归一为 0.9 和 0.1,中点就会变成 0.5,采样位置完全不同。

CPU 在三维三角形交点处使用重心系数插值 UV 是合理的。仍需确认 Math::interpolate 返回的重心系数与 v1、v2、v3 一一对应,且三角形顶点与交点位于同一坐标空间。GPU 默认的透视正确插值并不意味着 CPU 还需要对这种三维重心插值额外做一次透视除法。

修正后仍有差异时,检查采样过程

先查看 getNormalizedPixelFromRatio 的实现。它是否把 UV 乘以 width - 1、是否反转 V、是否把边界索引截断,都会影响结果。

如果 CPU 使用双线性过滤,Wrap 还必须作用于参与过滤的每个 texel 索引。对于宽度为 W 的纹理,一种常见的归一化坐标到 texel 空间的换算方式是:

x = u * W - 0.5

在 u = 0 附近,过滤会同时涉及纹理末端和起始端的 texel。如果只把 UV 转入一个周期,却在读取相邻 texel 时执行 Clamp,接缝仍然会与 GPU 的 Wrap 采样不同。正尺寸纹理的整数索引可按下面的方式循环:

inline int wrapIndex(int index, int size)
{
    // Precondition: size > 0.
    const int remainder = index % size;
    return remainder < 0 ? remainder + size : remainder;
}

接着统一过滤和 mipmap 条件。GPU 的 Texture2D.Sample 可能根据屏幕空间导数选择 mip level;如果 CPU 始终读取最高分辨率图像,两端仍可能出现清晰度和细节差异。排查时可先固定相同的 mip level,并使用相同的 point filtering,隔离寻址问题,再逐步恢复线性过滤和 mipmap。

如果纹理位置已经一致,但颜色或光照仍不同,再检查 sRGB 解码、normal map、TBN 和光照计算。可以先直接显示 base color 纹理采样值,减少这些因素对比较的干扰。

备注:内容仅供参考。