渲染基础知识 — 纹理采样与虚拟纹理(VT)
1. UV 坐标与纹理映射
1.1 UV 坐标是什么
每个顶点除了位置 (x,y,z),还存了一对 (u,v) 坐标,范围通常是 [0,1],对应纹理图片的横轴和纵轴:
纹理图片
(0,1) ┌──────────┐ (1,1)
│ │
│ 像素们 │
│ │
(0,0) └──────────┘ (1,0)
u →
建模软件里做"展 UV"就是把三维网格的每个三角面拍平到这张 2D 图上,告诉 GPU:"这个顶点对应纹理的这个位置"。
1.2 从顶点到像素:插值
顶点着色器把每个顶点的 UV 传给光栅化阶段,GPU 对三角形内部的每个像素做重心插值,算出该像素的精确 UV:
顶点 A (u=0, v=0)
顶点 B (u=1, v=0)
顶点 C (u=0.5, v=1)
三角形中心某像素 → 插值得到大约 (u=0.5, v=0.33)
1.3 片元着色器里的采样
拿到插值后的 UV,调用 tex2D / SAMPLE_TEXTURE2D 去纹理里取颜色:
// URP Shader 伪代码
half4 frag(Varyings i) : SV_Target
{
// i.uv 是光栅化插值后的精确值
half4 color = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.uv);
return color;
}
GPU 内部做的事:
1. 用 UV 乘以纹理分辨率,得到浮点像素坐标
texelPos = uv * textureSize // 比如 (0.5, 0.33) * 512 = (256, 169.0)
2. 根据过滤模式取色(见第 3 节)
1.4 Wrap Mode:UV 超出 [0,1] 怎么办
Repeat: uv = frac(uv) // 1.3 → 0.3,纹理平铺重复
Clamp: uv = clamp(uv, 0, 1) // 1.3 → 1.0,边缘像素拉伸
Mirror: 来回翻转
1.5 整条链路
建模展UV → 顶点携带(u,v) → 光栅化插值 → 片元拿到精确UV
→ 算ddx/ddy选mip → 按过滤模式采样texel → 得到该像素的纹理颜色
2. Texel 与 Pixel
-
Pixel — 屏幕上的像素
-
Texel — 纹理贴图里的像素(Texture + Pixel)
之所以区分,是因为两者不是一一对应的。一个屏幕 pixel 采样时可能覆盖多个 texel(物体远),也可能只覆盖不到一个 texel(物体近)。Mip 选择和过滤模式本质上就是在处理 pixel 和 texel 之间的这个比例关系。
3. 纹理过滤模式
三者是逐级递进的关系。
3.1 Point (Nearest)
取离采样点最近的那 1 个 texel,不做任何混合。
采样点落在 (256.3, 169.7)
→ 直接取 (256, 170) 这个 texel 的颜色
效果硬边、有锯齿感。适合像素风格游戏、UI 图标这类本身就不想模糊的场景。
3.2 Bilinear
取采样点周围 4 个 texel,按距离加权平均。
采样点 (256.3, 169.7)
→ 取 (256,169) (257,169) (256,170) (257,170) 四个 texel
→ 横向按 0.3/0.7 插值,纵向按 0.7/0.3 插值
→ 得到平滑的混合颜色
大多数 3D 场景的默认选择,平滑且开销低。但在两个 mip level 切换的边界会有一条明显的接缝。
3.3 Trilinear
在相邻两个 mip level 上各做一次 bilinear(共采 8 个 texel),再按 mip 的小数部分插值。
算出 mip = 2.4
→ mip 2 上做一次 bilinear → 颜色 A
→ mip 3 上做一次 bilinear → 颜色 B
→ lerp(A, B, 0.4) → 最终颜色
消除了 mip 切换的接缝,代价是多一倍采样。地面、墙壁这类大面积延伸到远处的表面最明显,用 trilinear 效果好。
3.4 对比
采样数 mip接缝 效果 开销
Point 1 有 硬边锐利 最低
Bilinear 4 有 平滑 低
Trilinear 8 无 更平滑 中等
4. 各向异性过滤(Anisotropic Filtering)
4.1 问题来源
Bilinear/Trilinear 采样时,假设一个屏幕像素在纹理上覆盖的区域是正方形。但当你斜着看一个表面(比如站在路上看远处地面),一个像素在纹理上覆盖的其实是一个被拉长的椭圆/长条形:
正对着看(各向同性): 斜着看(各向异性):
┌──┐ ┌──────────┐
│ │ ≈ 正方形 │ │ ≈ 长条形
└──┘ └──────────┘
如果还是用正方形去采样,GPU 只能选一个 mip level 来近似,要么:
-
选高 mip → 短边方向对了,但长边方向过度模糊
-
选低 mip → 长边方向清晰了,但短边方向出现摩尔纹
4.2 解决方式
不再用一次正方形采样,而是沿着长条方向多次采样再平均:
各向同性(1次采样): 各向异性 4x(沿长轴采4次):
┌──┐ ┌──┐┌──┐┌──┐┌──┐
│ │ │ ││ ││ ││ │
└──┘ └──┘└──┘└──┘└──┘
←── 沿拉伸方向排列 ──→
GPU 把那个长条形区域拆成多个小正方形,每个小正方形做一次 bilinear/trilinear,最后取平均。
4.3 Aniso Level 的含义
Aniso 1x = 关闭,等同普通 bilinear/trilinear
Aniso 2x = 最多沿长轴采 2 次
Aniso 4x = 最多 4 次
Aniso 8x = 最多 8 次
Aniso 16x = 最多 16 次,质量最高
"最多"是因为正对着看时长短轴比接近 1:1,GPU 自动退化成 1 次采样,不浪费。只有斜角越大,实际采样次数才越多。
4.4 适用场景
效果最明显的是大面积倾斜表面 — 地面、道路、墙壁、桌面。正对摄像机的物体(UI、Billboard)几乎没区别。Unity 里 Texture Import Settings 的 Aniso Level 一般地面纹理设 4-8,普通贴图 1-2 就够。
5. 虚拟纹理(Virtual Texture)
5.1 核心思路
VT 把一张巨大的纹理切成固定大小的小块(page),只把当前画面用到的 page 加载到显存,而不是整张纹理全部加载。
5.2 Page ID
Page ID 就是虚拟纹理中每个小块的网格坐标。
怎么切的
比如一张 16384x16384 的虚拟纹理,page 大小 128x128
横向切 16384/128 = 128 块
纵向切 16384/128 = 128 块
共 128x128 = 16384 个 page
每个 page 的 ID 就是它的网格坐标 (pageX, pageY)
(0,3) (1,3) (2,3) ...
(0,2) (1,2) (2,2) ...
(0,1) (1,1) (2,1) ...
(0,0) (1,0) (2,0) ...
加上 mip level 就是完整地址:(pageX, pageY, mip)。不同 mip 下切出的 page 数量不同,mip 越高 page 越少。
Page ID 如何计算
在 Feedback Shader 的片元着色器里,用插值后的 UV 直接算:
// 1. 把 [0,1] 的 UV 换算成虚拟纹理的 texel 坐标
float2 vt_uv = uv * _VTSize; // 比如 uv(0.25, 0.5) * 16384 = (4096, 8192)
// 2. 用 ddx/ddy 算出 mip level
float mip = CalcMipLevel(ddx(vt_uv), ddy(vt_uv)); // 比如算出 mip = 2
// 3. mip 2 意味着分辨率缩小 4 倍(2^2),texel 坐标也要缩
float2 mipUV = vt_uv / pow(2, mip); // (4096, 8192) / 4 = (1024, 2048)
// 4. 除以 page 大小,取整,就是 page ID
int2 pageID = floor(mipUV / _PageSize); // (1024, 2048) / 128 = (8, 16)
// 结果:这个片元需要 mip 2 下的 page (8, 16)
本质就是两步:UV 告诉你在纹理的哪个位置,除以 page 大小再取整,就知道落在哪个格子里。
Page ID 的作用
Feedback Pass → 收集到这帧需要 (3,5,2) (7,1,0) (12,8,3) ...
↓
CPU 检查哪些 page 已在物理缓存中,哪些还没加载
↓
没加载的 → 从磁盘读取对应 page 数据 → 上传到物理纹理缓存
↓
更新页表(Page Table):page ID → 物理缓存中的实际位置
↓
正常渲染时,shader 通过页表把虚拟 UV 转成物理 UV,采样到正确的 page
6. Feedback RT(Feedback Render Target)
6.1 是什么
一张专门用来收集"屏幕上每个像素实际需要哪块虚拟纹理页"的渲染目标。不上屏,玩家看不到它。
正常 RT: 每像素存 (R, G, B, A) → 最终显示的颜色
Feedback RT:每像素存 (pageX, pageY, mip, ...) → 编码后的页请求
6.2 完整流程
1. 用一个特殊的 Feedback Shader 做一遍渲染(通常降分辨率,比如 1/8)
2. 每个片元算出自己需要的 page ID 和 mip level,编码成颜色写入 Feedback RT
3. CPU 回读这张 RT(ReadBack)
4. 解码所有像素,得到一份"当前帧需要哪些 page"的列表
5. 把还没加载的 page 从磁盘/内存调入物理纹理缓存
6.3 为什么降分辨率
全分辨率回读太贵。一个 page 通常覆盖很多像素,1/8 分辨率已经足够捕捉到所有需要的 page,漏掉的极少数会在下一帧补上,视觉上看不出差别。
6.4 低分辨率如何写入
不是片元去"对应"Feedback RT 的点,而是 Feedback RT 的分辨率直接决定了光栅化产生多少个片元。渲染目标的分辨率决定光栅化粒度:
同一个三角形覆盖屏幕同一块区域:
画到 1920x1080 RT → 光栅化产生比如 1000 个片元
画到 240x135 RT → 光栅化产生比如 16 个片元
Feedback Pass 的做法:把渲染目标设成低分辨率的 Feedback RT,然后正常画场景。GPU 光栅化时自动按这个低分辨率生成片元,每个片元自然对应 Feedback RT 上的一个像素。
1. SetRenderTarget(feedbackRT) // 240x135
2. SetViewport(240, 135)
3. 用 Feedback Shader 画场景中所有物体
GPU 自动做的事:
- 顶点变换到裁剪空间(和正常渲染一样)
- 视口变换时映射到 240x135 而不是 1920x1080
- 光栅化按 240x135 的网格生成片元
- 每个片元跑 Feedback Shader,写入对应像素
片元着色器里的代码和全分辨率时完全一样,ddx/ddy 也是对的 — 因为相邻片元之间的 UV 跨度变大了,算出的 mip level 会自然偏高一点,这反而是合理的。
6.5 降分辨率不影响画面
Feedback RT 不上屏,玩家看到的画面来自另一个正常分辨率的渲染 Pass:
Feedback Pass (240x135) → CPU 读取 → 调度 page 加载 → 玩家看不到
正常渲染 Pass (1920x1080) → 用已加载的 page 采样 → 玩家看到的画面
降 Feedback RT 分辨率不影响画面质量,最多影响 page 调度的及时性(偶尔晚一两帧加载)。
6.6 不同分辨率回读的权衡
分辨率 像素数 回读开销 漏页风险 典型用途
1/1 ~2M 很高 几乎无 几乎没人用
1/4 ~130K 中等 很低 高质量需求
1/8 ~32K 低 偶尔小物体 主流选择
1/16 ~8K 很低 明显 移动端/低端机
漏页不会崩,只是那块区域这一帧用低 mip 的模糊纹理顶着,下一帧大概率就补上了。
7. VT 中的过滤模式选择
7.1 Feedback RT 必须用 Point 过滤
因为 Feedback RT 里存的不是颜色,而是编码后的数据 (pageX, pageY, mip)。
像素 A 编码了 (page 3, mip 2) → 颜色值 (3, 2, ...)
像素 B 编码了 (page 5, mip 4) → 颜色值 (5, 4, ...)
Point: 读 A 就是 (3, 2) ✓ 精确的 page 请求
Bilinear: 读 A 可能变成 (4, 3) → 一个根本不存在的 page 请求 ✗
凡是纹理里存的是离散标识符而不是连续颜色值,都必须用 Point 过滤。插值会制造出无意义的中间值。
7.2 Physical Cache 必须用 Bilinear 过滤
Physical Cache 最终是给正常渲染用的,采样出来的是要显示的颜色,需要平滑过渡。
但 page 之间的边界有问题 — Bilinear 采样会取周围 4 个 texel,如果采样点在 page 边缘,就会采到相邻 page 的 texel,而相邻的 page 在虚拟纹理里可能根本不挨着。
解决方案:Border Padding
每个 page 存入 Physical Cache 时,四周多加一圈(通常 1-4 texel)从虚拟纹理中相邻 page 复制过来的边界像素:
实际存储的不是 128x128,而是 130x130(假设 border=1):
┌─┬────────┬─┐
│b│ │b│ b = border 像素(来自虚拟纹理中真正相邻的 page)
├─┤ 128x128├─┤
│b│ 正文 │b│
├─┤ ├─┤
│b│ │b│
└─┴────────┴─┘
这样 Bilinear 在 page 边缘采样时,取到的相邻 texel 是正确的虚拟纹理内容,而不是 Physical Cache 里碰巧挨着的无关 page。
8. 页表查找:虚拟 UV → 物理 UV
正常渲染时,Shader 通过页表(Page Table)做一次间接寻址,把虚拟 UV 转成物理 UV,再从 Physical Cache 中采样真正的颜色。
8.1 页表(Page Table)是什么
一张小的查找纹理(Indirection Texture),每个 texel 对应虚拟纹理的一个 page,存的是这个 page 在 Physical Cache 中的实际位置:
页表纹理(比如 128x128,对应 128x128 个 page)
每个 texel 存:
R = physical_x // 在物理缓存中的列号
G = physical_y // 在物理缓存中的行号
B = valid/mip // 是否已加载 / 实际可用的 mip
8.2 Shader 中的转换过程
half4 frag(Varyings i) : SV_Target
{
float2 vt_uv = i.uv;
// 1. 用虚拟 UV 去页表里查:这个 page 在物理缓存的哪个位置
// 页表很小,直接用 UV 采样就是在查表
float4 page_info = tex2D(_PageTable, vt_uv); // Point 过滤!
// page_info.rg = 物理缓存中的 page 位置 (比如第 3 列第 7 行)
// page_info.b = 实际可用的 mip level
// 2. 算出虚拟 UV 在当前 page 内部的偏移(page 内的小数部分)
float2 inPageOffset = frac(vt_uv * _PageCount);
// 比如虚拟纹理有 128x128 个 page
// uv(0.253, 0.5) * 128 = (32.384, 64.0)
// frac → (0.384, 0.0) ← 在 page 内部偏移了 38.4%
// 3. 拼出物理 UV = page 在缓存中的起始位置 + 内部偏移
float2 physical_uv = (page_info.rg + inPageOffset) / _PhysicalCacheSize;
// page_info.rg = (3, 7),缓存是 32x32 个 page 的格子
// (3 + 0.384, 7 + 0.0) / 32 = (0.1058, 0.2188)
// 4. 用物理 UV 去 Physical Cache 采样真正的颜色
half4 color = tex2D(_PhysicalCache, physical_uv); // Bilinear 过滤
return color;
}
8.3 图示
虚拟纹理 (16384x16384) Physical Cache (4096x4096)
┌───┬───┬───┬───┐ ┌───┬───┬───┬───┐
│ │ │ │ │ │ D │ A │ F │ H │
├───┼───┼───┼───┤ 页表查找 ├───┼───┼───┼───┤
│ │ A★│ │ │ ──────────→ │ B │ G │ C │ E │
├───┼───┼───┼───┤ A → 缓存(1,0) ├───┼───┼───┼───┤
│ │ │ │ │ │ │ │ │ │
└───┴───┴───┴───┘ └───┴───┴───┴───┘
★ 采样点在这 ★ 实际从这取色
虚拟 UV → 查页表 → 得到物理位置(1,0) + page 内偏移 → 物理 UV → 采样颜色
8.4 _PageTable 与 _PhysicalCache 的本质
它们就是两张普通的 Texture2D,没有任何特殊类型,由 CPU 端创建并动态更新内容。
// === _PageTable(页表纹理)===
// 一张很小的纹理,比如 128x128,每个 texel 是一条映射记录
Texture2D pageTable = new Texture2D(128, 128, TextureFormat.RGBA32, false);
pageTable.filterMode = FilterMode.Point; // 必须 Point,存的是地址
// 每当 page 加载/卸载,更新对应 texel
pageTable.SetPixel(pageX, pageY, new Color(physX, physY, mip, valid));
pageTable.Apply();
material.SetTexture("_PageTable", pageTable);
// === _PhysicalCache(物理缓存纹理)===
// 一张较大的纹理,比如 4096x4096,存真正的颜色数据
Texture2D physicalCache = new Texture2D(4096, 4096, TextureFormat.RGBA32, false);
physicalCache.filterMode = FilterMode.Bilinear; // 必须 Bilinear,采的是颜色
// 当需要加载某个 page 时,把像素数据写入对应格子
physicalCache.SetPixels(startX, startY, 130, 130, pagePixels); // 128 + 2 border
physicalCache.Apply();
material.SetTexture("_PhysicalCache", physicalCache);
8.5 关键点
-
页表用 Point 过滤 — 和 Feedback RT 同理,存的是离散地址,不能插值
-
Physical Cache 用 Bilinear — 采的是颜色,需要平滑
-
整个过程是两次纹理采样:一次查页表,一次取颜色,所以叫 "two-tap" 方案
-
如果某个 page 还没加载,页表里会指向一个低 mip 的 fallback page,画面暂时模糊但不会穿帮
-

浙公网安备 33010602011771号