图集图标边缘串色:Clamp 为何挡不住相邻图块
背包图标贴到同一张图集之后,右边缘出现了隔壁图标的颜色。纹理地址模式已经设为 Clamp,局部 UV 也没有超过 1,串色仍然存在。此时继续检查 UV 是否越界容易找错边界:采样位置处于整张图集内部,过滤器完全有权读取右边那一列。

上图表达相邻图块与过滤足迹的关系,不作为像素测量。把问题缩成八乘四的标量纹理,左侧四列全为 0,右侧四列全为 1,就能把输出直接读成邻图块的贡献比例。本文的 C# 参考核运行于 .NET 9,模拟固定单层双线性过滤;没有调用 Unity 或 GPU,也没有把这些数值当成实际设备截图。
右边界落在两个像素中心之间
局部横坐标为 u,图块左边界为 x0、宽度为 w,整张纹理宽度为 W。把图块边界映射到图集,归一化坐标是 U=(x0+u*w)/W。过滤时以像素中心为整数索引,连续索引为 x=U*W-0.5。
在左侧图块的右边界,x0=0、w=4、W=8、u=1,所以 U=0.5、x=3.5。索引 3 和 4 各占一半,输出就是 0.5。地址 Clamp 只会处理整张纹理外面的索引;3 和 4 都合法,不能期待它理解哪个索引属于某个图标。
这不是只发生在端点的特例。最后一个内容像素中心对应局部 u=0.875;从这里继续向右,右邻权重逐渐增长。若图块右方与下方都是其他内容,恰好采到右下角时,四个等权样本中只有一个属于本图块,外来贡献达到 0.75。测试 TwoDimensionalCornerHasThreeForeignTaps 单独锁住这个二维反例。
把两个端点内缩,会同时移动内部样本
常见修法把左端改为第一个像素中心,右端改为最后一个像素中心。这对应 x0+0.5+u*(w-1)。双线性足迹不再跨越内容边界,但映射跨度也从 w 变成了 w-1。
用同一个四列图块,把列值换成 0、1、2、3。局部 u=0.25 原本落在连续索引 0.5,得到 0.5;全域内缩后落到 0.75,得到 0.75。于是“没有邻图颜色”与“保持原图采样位置”成为两个不同的验收条件。小尺寸图标、细线和像素图案尤其应分开检查这两件事,不能仅靠纯色块判断修复完全正确。
如果需求是模拟独立纹理的 Clamp,可以保留边界映射,只在采样点处把坐标钳制到本图块首尾像素中心。内部坐标不动,靠边的半个像素范围延续边缘值。这里的钳制需要作用于插值后的采样坐标;仅把四个顶点 UV 改成内缩端点,再由光栅器插值,仍然是前一种全域缩放。
AtlasFilter.cs 的 Map 提供两种策略,Sample 明确实现四点权重。调用链为 Program.cs → Read → Map → Sample,如下是完整参考核。数组按 [y,x] 访问,坐标采用同一行方向;颜色空间转换与 Alpha 混合不在这个标量模型内。
public readonly record struct Tile(int X, int Y, int Width, int Height);
public static class AtlasFilter
{
public static (double X, double Y) Map(Tile tile, double u, double v,
int width, int height, bool inset = false, bool localClamp = false)
{
if (width <= 0 || height <= 0 || tile.Width <= 0 || tile.Height <= 0 ||
tile.X < 0 || tile.Y < 0 || (long)tile.X + tile.Width > width ||
(long)tile.Y + tile.Height > height ||
!double.IsFinite(u) || !double.IsFinite(v) ||
u < 0 || u > 1 || v < 0 || v > 1)
throw new ArgumentException("图块与局部坐标必须有效");
double x = tile.X + (inset ? 0.5 + u * (tile.Width - 1) : u * tile.Width);
double y = tile.Y + (inset ? 0.5 + v * (tile.Height - 1) : v * tile.Height);
if (localClamp)
{
x = Math.Clamp(x, tile.X + 0.5, tile.X + tile.Width - 0.5);
y = Math.Clamp(y, tile.Y + 0.5, tile.Y + tile.Height - 0.5);
}
return (x / width, y / height);
}
public static double Sample(double[,] texels, double u, double v)
{
int height = texels.GetLength(0), width = texels.GetLength(1);
if (width == 0 || height == 0 || !double.IsFinite(u) || !double.IsFinite(v))
throw new ArgumentException("纹理和坐标必须有效");
// 固定单层双线性过滤,地址模式为整张纹理 Clamp。
double x = Math.Clamp(u, 0, 1) * width - 0.5;
double y = Math.Clamp(v, 0, 1) * height - 0.5;
int ix = (int)Math.Floor(x), iy = (int)Math.Floor(y);
double tx = x - ix, ty = y - iy;
double At(int px, int py) => texels[Math.Clamp(py, 0, height - 1),
Math.Clamp(px, 0, width - 1)];
double a = At(ix, iy) * (1 - tx) + At(ix + 1, iy) * tx;
double b = At(ix, iy + 1) * (1 - tx) + At(ix + 1, iy + 1) * tx;
return a * (1 - ty) + b * ty;
}
}
边缘扩展把隔离责任交回图集构建
另一条路径是在内容周围复制一圈边缘像素,角上也复制角像素。测试把图集改为十乘六,内容矩形移到 (1,1,4,4),第 0 与第 5 列延续内容首尾值,上下行同理。UV 仍映射内容边界,过滤可以跨出内容矩形,但读到的是相同边缘的副本。
这种方法同时保留了上述内部样本的 0.5,并让边界外来贡献为零。代价是占用空间和维护扩展区。对于四乘四内容,一圈扩展对应六乘六的占用矩形,像素数从 16 增到 36;这是本例的几何面积,不是显存或性能测量。动态更新图标时,内容与边缘副本必须来自同一版本,否则旧颜色仍会留在过滤足迹里。

技术图使用运行 JSON 中的 41 个横向样本。曲线重合只证明纯色隔离样本输出相同;右侧渐变比较进一步暴露全域内缩的内容偏移。
InternalEdgeLeaksDespiteAtlasClamp 将边界输出 0.5 作为必须重现的错误样本;InsetRescalesInterior 同时断言修复前后内部值不同,防止把内容变形掩盖在防串色测试里。二维网格还对 10201 组局部坐标检查内缩与局部钳制的外来贡献为零,另有单像素图块、非法矩形、非有限数和越界局部坐标回归。
2026-09-26 执行以下命令,$LAB_ROOT 指向包含这些源码的研究目录:
dotnet build "$LAB_ROOT/AtlasLab.csproj" -c Release
dotnet run --project "$LAB_ROOT/AtlasLab.csproj" -c Release --no-build
build: exit=0 warnings=0 errors=0
run: exit=0 passed=12 failed=0 skipped=0 elapsedMs=28.284
gridPoints=10201 edgeLeak=0.5 cornerLeak=0.75
originalInterior=0.5 insetInterior=0.75 maxSafeLeak=0
耗时包含测试和固定曲线求值,不含构建与 JSON 输出,不能据此比较 GPU 采样成本。测试通过表示错误样本被正确复现、修复行为符合断言,而不是朴素映射已经没有错误。
图集构建器应交付内容矩形、扩展区和允许的采样范围;渲染端按同一份合同选择映射。没有扩展区时,固定单层局部钳制可以表达独立纹理边缘;希望保持边界映射时,则应由资产构建维护复制边缘,避免每个 UI 组件自行猜偏移量。
一圈扩展的保证止于本次单层双线性足迹。粗 Mip 生成、跨层混合、各向异性足迹和块压缩都可能重新引入邻图数据;若粗层像素已经混色,再钳制坐标也不能恢复原内容。需要这些能力时,应重新设计逐层隔离、扩展预算或采用独立层存储,并在目标后端验证。背包边缘的颜色是否干净,最终取决于过滤能读取哪一圈数据,而不只是 UV 有没有落在零到一之间。