运行时纹理第二行错位,通常不是采样问题而是上传行跨度
一张由 CPU 生成的 3×2 RGBA 纹理,第一行颜色正确,第二行却从中间开始错位。此时去改 UV、过滤模式或采样器只会把症状变得更模糊:真正出错的位置可能早于 Shader,在像素从紧密数组进入上传内存的那一步。

这个案例故意选用宽度为 3 的 RGBA8 纹理。源数据每行正好是 3 × 4 = 12 字节,而探针模拟的上传接口要求行首按 8 字节对齐,因此目标行跨度为 16 字节。两行源数据只有 24 字节,上传缓冲却必须占 32 字节;多出来的 8 字节不是可忽略的尾部,而是分布在每一行末尾的填充。
一次 memcpy 为什么会破坏第二行
若把 24 字节源数组一次复制到 32 字节候选缓冲,第二行会紧跟在偏移 12。设备却按 rowPitch=16 查找第二行,于是它从第二行第 2 个像素开始读取,并把后面的零填充当作像素数据。错误不是颜色格式转换,而是源布局与目标布局被当成了同一种布局。
RuntimeTextureUpload.cs 中的提交入口把这两个量分开:tightRowBytes 描述调用者交付的数据,rowPitch 描述设备上传合同。复制只能逐行发生。
public UploadResult TryCommit(
int version,
int width,
int height,
ReadOnlySpan<byte> tightRgba)
{
if (version <= Current.Version)
{
return UploadResult.StaleVersion;
}
int tightRowBytes = checked(width * 4);
int expectedLength = checked(tightRowBytes * height);
if (width <= 0 || height <= 0 || tightRgba.Length != expectedLength)
{
return UploadResult.InvalidSourceLength;
}
int rowPitch = checked((tightRowBytes + 7) / 8 * 8);
byte[] candidate = new byte[checked(rowPitch * height)];
for (int row = 0; row < height; row++)
{
tightRgba.Slice(row * tightRowBytes, tightRowBytes)
.CopyTo(candidate.AsSpan(row * rowPitch, tightRowBytes));
}
if (!device.TryUpload(width, height, rowPitch, candidate))
{
return UploadResult.DeviceRejected;
}
Current = new TextureFrame(
version, width, height, rowPitch, candidate);
return UploadResult.Committed;
}
长度校验也必须在分配和复制之前完成。只检查“至少有这么多字节”会允许调用者把额外行、错误像素格式或旧尺寸的缓冲混进当前提交;checked 则让尺寸乘法溢出成为明确失败,而不是产生一个过小的候选数组。

上传成功之前,候选纹理没有可见资格
行复制正确仍不足以保证画面稳定。设备可能因为资源状态、尺寸限制或上下文丢失拒绝上传。如果代码先把 Current.Version 改成 6,再调用设备,失败后渲染端会看到一个声称已更新、内容却仍属于旧版本的对象。
探针让 RuntimeTextureOwner 独占活动快照。版本、尺寸、行跨度和字节数组先组成不可见候选,设备确认上传成功后才替换 Current。版本校验还挡在设备调用之前,因此迟到版本不会消耗上传工作,更不会覆盖新纹理。
这里的“整体替换”还约束了渲染线程能读到什么。若更新线程分别改写宽度、行跨度和缓冲引用,渲染线程可能在三个写操作之间取值,得到新宽度配旧缓冲的混合状态。不可变 TextureFrame 把这些字段绑定到同一个版本,公开点只替换一次引用。探针在单线程中验证提交结果,生产接入仍需用引擎主线程切换、锁或原子引用发布来建立跨线程可见性;具体同步机制可以变化,但读端不能观察半份纹理描述。
固定输入的决定性结果如下:
$ dotnet run --project $WORK_DIR/.tmp/daily-labs/2026-08-19-1930/RuntimeTextureUploadProbe.csproj -c Release --no-build
valid: version=5 size=3x2 tight_row_bytes=12 upload_row_pitch=16 upload_bytes=32
broken: second_row_offset=12 expected_offset=16 offset_error=4
rejected: candidate_version=6 active_version=5 result=DeviceRejected
tests: pass=8 fail=0 skip=0 duration_ms=9.31
当天 Release 构建退出码为 0,0 警告、0 错误,墙钟 13.64 秒;运行退出码为 0,墙钟 3.16 秒。八项断言覆盖两行内容、行尾填充、错误整块复制、设备拒绝和陈旧版本。耗时只证明本次探针完成,不是纹理上传性能数据。
对齐规则不应藏在调用者猜测里
本切片使用 8 字节对齐是为了让 3 像素宽度稳定暴露填充,并不声称真实图形 API 都采用这个数值。接入 Unity 原生插件、自研渲染后端或平台图形接口时,对齐要求应由上传适配层提供;调用者只交付明确格式的紧密像素和尺寸。若来源本来已有自己的行跨度,接口还应显式携带 sourceRowPitch,不能先假设紧密再重复打包。
当前实现也没有覆盖异步 GPU 完成信号。设备返回成功在探针中代表复制已经被接受;真实后端若只完成命令入队,活动句柄的交换和旧资源回收还必须受栅栏或帧代次约束。压缩纹理、分块更新、Mip 链与多平面格式会引入块跨度和切片跨度,不能套用 width × 4。
频繁更新时也不应因为当前数组写法就推导出“每帧分配必然合理”。候选缓冲可以来自受控池或持久映射区,但归还时机必须晚于设备读取完成,而且复用单元要连同容量、行跨度和占用代次一起管理。当分析数据显示分配压力或上传等待成为瓶颈,再把候选存储演进为环形缓冲;在此之前,先保留清楚的失败隔离,比提前共享一块可变内存更容易证明正确。
开篇的第二行错位因此有一个可检查的边界:源像素布局由生产者定义,上传布局由设备适配层定义,提交者逐行完成两者之间的转换;只有完整候选通过设备后,渲染端才看见新版本。这样采样器继续只负责采样,破图也会停在具有明确拒绝原因的上传入口,而不是进入下一帧画面。