渲染基础知识 — 纹理采样与虚拟纹理(VT)

渲染基础知识 — 纹理采样与虚拟纹理(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,画面暂时模糊但不会穿帮

  • VT 系统的"虚拟"全靠 CPU 端动态更新这两张纹理的内容来实现 — 页表记录地址,物理缓存存放数据,Shader 里两次采样完成间接寻址

posted @ 2026-04-02 20:27  漫世界  阅读(138)  评论(0)    收藏  举报