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.2 | 0.8 | 约 0.2 |
| -0.2 | 0.8 | 约 0.2 |
| 0.2 | 0.2 | 约 0.2 |
| 1.0 | 0.0 | 1.0 |
| 1.2 | 0.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 纹理采样值,减少这些因素对比较的干扰。
备注:内容仅供参考。
