倾斜表面的 UV 插值:屏幕重心为何不能直接当作表面权重
倾斜地板上的纹理坐标,在三个角点都正确,三角形内部却仍可能被拉歪。对一组固定输入,屏幕重心直接加权得到 UV (0.25,0.5),同一个投影点在原表面上对应的 UV 却是 (0.25,0.25)。这时换纹理、调整过滤器都不会修复采样位置,因为偏差发生在纹理被读取之前。
为了把这个差异单独测清,固定相机空间三角形 A=(0,0,1)、B=(2,0,2)、C=(0,4,4),坐标使用同一长度单位。投影只取 (x/z,y/z),令齐次分量 W=z;这是一套正 Z 朝前的数学约定,不借用任何引擎的默认轴向。三个顶点投影到 (0,0)、(1,0)、(0,1),角点 UV 分别为 (0,0)、(1,0)、(0,1),并约定 UV 在原三角形上按表面重心线性变化。

主视觉表达表面与图像空间的区别,不承担相机标定或数值验收;下面的固定三角形才是计算依据。
屏幕上的一半,不等于表面上的一半
取投影位置 P=(0.25,0.5),它的屏幕重心 λ 为 (0.25,0.25,0.5)。直接用 λ 混合 UV,V 自然得到 0.5。但原表面重心 (0.5,0.25,0.25) 对应三维点 (0.5,1,2),投影后恰好也是 P;该表面点的 UV 是 (0.25,0.25)。两个结果描述了不同的插值空间。
原因来自透视除法。设原表面重心为 α,各角点的齐次坐标为 (Xᵢ,Yᵢ,Wᵢ)。先在表面组合再投影,分母是 ΣαᵢWᵢ,因此屏幕重心满足 λᵢ=αᵢWᵢ/ΣαⱼWⱼ。由 Σαᵢ=1 反解,就得到 αᵢ=(λᵢ/Wᵢ)/Σ(λⱼ/Wⱼ)。把这份权重用于表面属性 a:
a(P) = sum(lambda[i] * a[i] / W[i]) / sum(lambda[i] / W[i])
lambda / W = (0.25, 0.125, 0.125)
sum(lambda / W) = 0.5
surface weights = (0.5, 0.25, 0.25)
UV(P) = (0.25, 0.25)
这也解释了为什么正对相机的样本容易漏掉问题:三个 W 相等时,公共因子会约掉,透视校正退化成屏幕仿射插值。只测顶点或等深平面,无法区分两种实现。只把 UV 除以 W 再求和也不完整;本例分子为 (0.125,0.125),还必须除以分母 0.5。

插值入口必须拿到透视除法之前的 W
研究工程 Perspective.cs 的 Perspective.TryInterpolate 接收三个角点的 W、原始 UV 和已经确认覆盖的屏幕重心。它不从屏幕坐标猜深度,也不接收已经校正过的权重。Uv 是返回候选,调用方只在成功时使用它。
public readonly record struct Uv(double U, double V);
public readonly record struct Corner(double W, Uv Texture);
public static class Perspective
{
public static bool TryInterpolate(Corner[] corners, double[] lambda,
out Uv candidate)
{
candidate = default;
if (corners.Length != 3 || lambda.Length != 3) return false;
if (lambda.Any(x => !double.IsFinite(x) || x < 0) ||
Math.Abs(lambda.Sum() - 1) > 1e-12) return false;
if (corners.Any(c => !double.IsFinite(c.W) || c.W <= 0 ||
!double.IsFinite(c.Texture.U) || !double.IsFinite(c.Texture.V)))
return false;
// 统一缩放倒数权重,避免直接计算极小 W 的倒数。
double minimumW = corners.Min(c => c.W);
double denominator = 0, u = 0, v = 0;
for (int i = 0; i < 3; i++)
{
double weight = lambda[i] * (minimumW / corners[i].W);
denominator += weight;
u += weight * corners[i].Texture.U;
v += weight * corners[i].Texture.V;
}
if (!double.IsFinite(denominator) || denominator <= 0) return false;
u /= denominator;
v /= denominator;
if (!double.IsFinite(u) || !double.IsFinite(v)) return false;
candidate = new Uv(u, v);
return true;
}
}
代码把所有倒数权重统一乘上最小 W,分子分母会同时缩放,数学结果不变。这样避免直接计算极小 W 的倒数,但不能消除浮点动态范围限制:W 比例极端时某些权重仍可能下溢;若分母归零,当前实现返回失败。本切片用 double 验证公式,没有证明半精度或单精度插值达到同样误差。
这里还有一条容易被忽略的分工。λ 必须非负,和与 1 的偏差不超过 1e-12;三角形是否覆盖该采样点,应由上游覆盖规则确定。把负值一律钳成零,会让原本在三角形外的输入得到看似有效的 UV,因此入口有意不做这种修复。零 W、负 W 和非有限输入也被拒绝;穿过相机平面的三角形需要上游先完成齐次裁剪,不能靠本函数跨过奇点。
从原表面取点,再检验能否找回它
Program.cs 的调用链为 Main → ProjectionGrid → Read → TryInterpolate。回归不是把同一个校正公式重写一遍当作答案:ProjectionGrid 先在原三角形上取重心 (1-b-c,b,c),直接计算三维点,再除以 z 投影,最后把屏幕重心送入待测函数,要求找回原来的 UV (b,c)。
b、c 以 1/32 为步长,满足非负且和不超过 1,共 561 个点。原表面第一重心严格为零的那条边,测试端显式传入屏幕零权重,避免 1-x/z-y/z 的减法舍入把已知边界变成微小负数;这只是固定样本的解析边界处理,不代替完整光栅器的边规则。另一个断言明确要求 (-1e-16,0.5,0.5) 被拒绝。
错误仿射结果保留在 affine-regression 中,要求 V 差值恰好为 0.25。InvalidInputs 还检查无效 W、无效 UV、错误数组长度及重心和;失败返回后,调用方保留先前值:
var invalid = Corners.ToArray();
invalid[2] = invalid[2] with { Texture = new(double.NaN, 0) };
Uv active = new(7, 8);
if (Perspective.TryInterpolate(invalid, Lambda, out var candidate)) active = candidate;
Check("failure-keeps-active", active == new Uv(7, 8));
等 W、三个顶点和四种公共 W 缩放分别验证退化行为与齐次比例不变性。2026-09-11 使用 .NET SDK 9.0.202、net9.0 Release 执行,$PROBE_DIR 指向包含上述源码与项目文件的研究目录:
cd "$PROBE_DIR"
dotnet build PerspectiveLab.csproj -c Release
dotnet run --project PerspectiveLab.csproj -c Release --no-build
构建退出码 0,警告 0、错误 0,耗时 8.93 秒;运行退出码 0,实际输出为:
passed=22 failed=0 skipped=0 samples=561 duration_ms=28.4783
lambda=0.25,0.25,0.5 w=1,2,4 affine_uv=0.25,0.5 corrected_uv=0.25,0.25
maximum_error=3.33067E-16 failure_keeps_active=7,8
这里的耗时是 CPU 功能检查记录,不是纹理采样性能或 GPU 吞吐量。图中的数值来自同一份输出数据,主视觉也不能被当作引擎截图;本次尚未进行 GPU 插值器、裁剪、MSAA 采样位置或纹理过滤的像素回读。
接口应说明属性属于哪个空间
对自建软件光栅器、离线贴图工具或需要恢复表面属性的 CPU 流程,顶点到插值阶段的接口应保留 clip W 与原始属性。只留下投影后的二维点,三角形覆盖仍可计算,表面属性的透视恢复却已经丢失必要信息。在已经提供透视校正插值的管线里,再做一次相同修正也会改变结果;接入时要确认输入究竟是屏幕 λ、表面 α,还是已经插值完成的 UV。
本公式适用于原三角形上线性的属性。希望按屏幕距离线性变化的效果,应明确选择屏幕空间合同;不能因为名称也叫“插值”就替换它。裁剪后的深度值也不能不加区分地当成原始 UV 套入相同路径。
倾斜地板的采样偏差现在可以在读纹理之前定位:固定点的 V 应当是 0.25,而不是 0.5。保留 W、明确权重空间、把覆盖与属性恢复分开,才能让后续过滤器处理正确位置上的样本;至于图像是否最终无缝,仍需在目标渲染后端验证裁剪、覆盖与采样的完整组合。