很长一段时间里,我都对游戏中各种贴图的分辨率规范认知处于一种混沌的状态,比如离镜头远的给1024分辨率,离镜头近的给2048分辨率,大物体给2048分辨率,小物体给512分辨率等等,也就是纯凭感觉,虽然不科学,但it works!

直到某天刷知乎看到了关于此类问题的讨论和技术分享,方才恍然大悟

本篇文章是对ProjectS中纹素密度选择的过程总结,以及相关工具分享

Mipmap验证工具制作

源代码来自我该给一个3D物体用多大的贴图?——游戏工程中快速确定物体贴图分辨率的实践方法,做了一些优化整理

内容也很简单,就是手动将对应分辨率的基元mipmap写入纹理中,具体优化为:

  • 支持0-7级的mipmap指定,生成对应的纹理,实际上7级的时候纹理大小已经来到8192分辨率了,再高也没必要
    • 0代表只生成一张64分辨率的纹理,仅有mip0
    • 1代表生成一张128分辨率的纹理,mip1为64
    • 2代表生成一张256分辨率的纹理,mip1为128,mip2为64
    • 。。。
  • 以PC平台为例,最后对纹理做了一次BC7压缩优化,可以大幅减少纹理大小和渲染压力
  • 自动生成随机颜色
  • 自动匹配基元mipmap纹理

这个可视化的原理也很简单,因为我们每级mipmap的分辨率都是完全匹配的

例如miplevel7为64分辨率,就使用这张64分辨率的基元图片

image-20241002003810849

miplevel6为128分辨率,就用这张分辨率为64,但图片内容标识为128的基元纹理铺满整个128分辨率的mipmap

image-20241002004007763

如此一来,我们严格保证了分辨率和图片显示内容一致,自然能直接将渲染结果作为指导的分辨率使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
using Sirenix.OdinInspector.Editor;
using Sirenix.OdinInspector;
using UnityEngine;
using UnityEditor;

namespace NKG.ProjectS
{
/// <summary>
/// 为了美观,每个基元纹理都是64分辨率
/// mipmax为64,mipmax - 1为128,mipmax - 2为256,mipmin为 (2 ^(mipmax - mipmin) x 64)
/// </summary>
public class MipmapGeneratorEditor : OdinEditorWindow
{
[LabelText("使用说明")]
public string wikipediaURL = "整理自:https://zhuanlan.zhihu.com/p/415919803";

public const int maxMipLimit = 7;

[InfoBox("mipmax为64,mipmin为8192,对应值为0-7")]
[LabelText("Mip等级数量")][Range(0, maxMipLimit)] public int maxMipLevel = 7;

public Texture2D[] fillrateSourceTextures;
public Color[] fillrateSourceColors;

[FolderPath] public string targetSavePath;

[Button("创建")]
void Create()
{
int levelCount = maxMipLevel + 1;

if (fillrateSourceTextures == null || fillrateSourceTextures.Length != levelCount)
{
fillrateSourceTextures = new Texture2D[maxMipLimit + 1];
fillrateSourceTextures[0] = AssetDatabase.LoadAssetAtPath<Texture2D>("Packages/com.nkg.mipmapvis/8K.png");
fillrateSourceTextures[1] = AssetDatabase.LoadAssetAtPath<Texture2D>("Packages/com.nkg.mipmapvis/4K.png");
fillrateSourceTextures[2] = AssetDatabase.LoadAssetAtPath<Texture2D>("Packages/com.nkg.mipmapvis/2K.png");
fillrateSourceTextures[3] = AssetDatabase.LoadAssetAtPath<Texture2D>("Packages/com.nkg.mipmapvis/1K.png");
fillrateSourceTextures[4] = AssetDatabase.LoadAssetAtPath<Texture2D>("Packages/com.nkg.mipmapvis/512.png");
fillrateSourceTextures[5] = AssetDatabase.LoadAssetAtPath<Texture2D>("Packages/com.nkg.mipmapvis/256.png");
fillrateSourceTextures[6] = AssetDatabase.LoadAssetAtPath<Texture2D>("Packages/com.nkg.mipmapvis/128.png");
fillrateSourceTextures[7] = AssetDatabase.LoadAssetAtPath<Texture2D>("Packages/com.nkg.mipmapvis/64.png");
}

if (fillrateSourceColors == null || fillrateSourceColors.Length != levelCount)
{
fillrateSourceColors = new Color[maxMipLimit + 1];
fillrateSourceColors[0] = new Color(Random.Range(0, 1.0f), Random.Range(0, 1.0f), Random.Range(0, 1.0f), 1f);
fillrateSourceColors[1] = new Color(Random.Range(0, 1.0f), Random.Range(0, 1.0f), Random.Range(0, 1.0f), 1f);
fillrateSourceColors[2] = new Color(Random.Range(0, 1.0f), Random.Range(0, 1.0f), Random.Range(0, 1.0f), 1f);
fillrateSourceColors[3] = new Color(Random.Range(0, 1.0f), Random.Range(0, 1.0f), Random.Range(0, 1.0f), 1f);
fillrateSourceColors[4] = new Color(Random.Range(0, 1.0f), Random.Range(0, 1.0f), Random.Range(0, 1.0f), 1f);
fillrateSourceColors[5] = new Color(Random.Range(0, 1.0f), Random.Range(0, 1.0f), Random.Range(0, 1.0f), 1f);
fillrateSourceColors[6] = new Color(Random.Range(0, 1.0f), Random.Range(0, 1.0f), Random.Range(0, 1.0f), 1f);
fillrateSourceColors[7] = new Color(Random.Range(0, 1.0f), Random.Range(0, 1.0f), Random.Range(0, 1.0f), 1f);
}

int resolution = Mathf.FloorToInt(Mathf.Pow(2, maxMipLevel)) * 64;

Texture2D texture = new Texture2D(resolution, resolution, TextureFormat.RGBA32, levelCount, true);

// 填充从mipmax到mip0
for (int i = 0; i < maxMipLevel + 1; i++)
{
int width = resolution >> i;
int height = resolution >> i;

Texture2D sourcePatternTexture = fillrateSourceTextures[i + maxMipLimit - maxMipLevel];
int sourcePatternTextureWidth = sourcePatternTexture.width;
int sourcePatternTextureHeight = sourcePatternTexture.height;
Color fillColor = fillrateSourceColors[i + maxMipLimit - maxMipLevel];

Color[] texCol =
sourcePatternTexture.GetPixels(0, 0, sourcePatternTextureWidth, sourcePatternTextureHeight);

for (int p = 0; p < texCol.Length; ++p)
{
var col = texCol[p];
col *= fillColor;
texCol[p] = col;
}

int copyStepX = width / sourcePatternTextureWidth;
int copyStepY = height / sourcePatternTextureHeight;

for (int x = 0; x < copyStepX; ++x)
{
for (int y = 0; y < copyStepY; ++y)
{
texture.SetPixels(x * sourcePatternTextureWidth, y * sourcePatternTextureHeight,
sourcePatternTextureWidth, sourcePatternTextureHeight, texCol, i);
}
}
}

texture.Apply(false);
AssetDatabase.CreateAsset(texture,
string.IsNullOrEmpty(targetSavePath)
? $"Packages/com.nkg.mipmapvis/Generated/MipmapForCheck.asset"
: $"{targetSavePath}/MipmapForCheck.asset");
AssetDatabase.Refresh();

// BC7压缩下
EditorUtility.CompressTexture(AssetDatabase.LoadAssetAtPath<Texture2D>( string.IsNullOrEmpty(targetSavePath)
? $"Packages/com.nkg.mipmapvis/Generated/MipmapForCheck.asset"
: $"{targetSavePath}/MipmapForCheck.asset"), TextureFormat.BC7, TextureCompressionQuality.Best);

AssetDatabase.Refresh();
}

[MonKey.Command("Mipmap Generator", Help = "生成一张用于纹理合理性检测的多Mipmap的纹理", Category = "Tool")]
public static void ShowWindow()
{
GetWindow(typeof(MipmapGeneratorEditor));
}
}
}

项目接入

SRP项目的接入非常简单,直接利用Unity的RenderObject这个RenderFeature即可

当然对于非正常渲染的类型,例如手动Draw GPU Instance的物体,就需要手动替换纹理

案例分析

我们先将纹理sampler的tiling offset设置为合适的数值,例如300x300是比较真实的比例

image-20241002004946528

然后将贴图替换为我们上一步生成的mipmap检测纹理

设定Game窗口分辨率(2560x1440),调整游戏视角为最常用视角,结果如下

左侧为mipmap显示结果,右侧为正常的地形渲染结果

可以看到,屏幕中大部分纹理分辨率都在512,只有左下的极小部分分辨率上升到了1024分辨率,我们就可以将这张草地纹理的分辨率强制指定为512分辨率,这样既不会导致带宽浪费,又不会因为分辨率限制导致渲染效果模糊

当然因为纹理采样滤波器的存在,例如双线性插值,在像素覆盖了多个纹素的情况下(纹素空间方向并不能保证和屏幕空间方向完全平行),插值得到的结果依旧是有些模糊的,所以如果追求极致,可以将纹理分辨率x2调整为1024,从而让像素基本总能对应到某个纹素上,可以保证基本不会模糊

参考

浅谈容易被忽视的纹素密度(Texel Density)

我该给一个3D物体用多大的贴图?——游戏工程中快速确定物体贴图分辨率的实践方法