用 Unity 实现 3D 文字游戏
关于《文字游戏》
《文字游戏》是一款由台湾游戏团队Team9所开发的角色扮演解谜类文字游戏,已于2022年1月21日推出Microsoft Windows及Mac OS X完整版本。 在游戏设定中,世界是由汉字构成,而“字”除了是故事叙述与界面的载体,同时也是故事内的物件、人物和场景。参考像素、ASCII艺术风格,以方块字作为棋盘式地图上的点阵单位,玩家将操控“我”,从叙述与物件的谜题中找出线索,通过删减、搬移、拆解、组合改变句义、主导剧情。
(图1)
除了2D部分,游戏中还有3D部分: (图2)
视频如下 https://www.bilibili.com/video/BV1dK411G7aQ/ 重点是 18”、29”、1’22” 三处
微剧透警告 以下是游戏后期的一张室内画面,此时玩家以第一视角操控,可以任意旋转走动,所以可以看出是3D场景而非2D。 除了游戏本身的乐趣,从技术角度说,这种渲染方式也是一种特别的挑战。 (图3)
基本思路
对于一张正常的图C: (图4-1) 如果有一张蒙版图M: (图4-2) 那两图叠加就可以得到最终所需的图T,T = C * M: (图4-3)
关于第一张图C,最简单的方案是直接拿 framebuffer 里渲好的图,或者自己调用方法渲一张RT,这里采用第一种方案。 拿 framebuffer 最简单的方式是用后处理,所以以下讨论方案基于后处理。后处理的优点是前置渲染过程比较任意,后期可以作为特效任意开关。
另外一种方案是单独渲染一次,参考下面的图G,此时可以以降分辨率渲染。
那么问题是如何得到第二张图M: 我们观察可以发现,M可以看成是一张格子图G,每个格子标着对应的字符编号或者某种标记。 (图4-4)
如果再有一种办法把G中的字符编号转化成字符,就可以得到M:M = f(G)。 这里最容易想到的办法是预先把能用到的字渲成图(假如我们称他为E),然后在G中存放字符图E的uv。M = f(G, E) (图4-5)
而对于Unity来说,TMP 插件天生自带字体贴图,而且功能齐全容易维护。 所以这里图E直接使用 TMP_Font 的字体贴图: (图4-6)
解决了理论问题,下面可以研究实现了:
T = C * f(G, E)
这里C和E可以直接得到,G需要生成,f 需要在 Shader 里编码。
实现
考察TMP字体的逻辑,G是用来读字体贴图E的,所以需要存放一个 rect ,而不是unicode编码或者其他什么。而且G贴图的分辨率 = 屏幕分辨率/字的尺寸。 (以下假设屏幕尺寸是800×600,每个字大小是20) 这里我们假设屏幕的宽和高必须能整除。
一个简单方案: 我们从TMP_Font 读取每个字的Rect,赋值给替代Shader。 把整个场景用替代Shader渲染到低分辨率的RT(也就是G图)。
那么
问题1:如何得到G。
给一个物体指定一串文字,并且读取这串文字的 uv rect,通过替代Shader存进 RT。
解决1:这里以 Render 为单位,在场景的每个Render上挂一个脚本,在脚本里存好文字。当然也有更复杂的实现方案比如烘焙进贴图之类,这里采用最简单的方案。
(图5-1)
在 RenderToText 的 Start 方法中把字符转化为 rect 数组,传给材质。
private void Start()
{
var mat = child.GetComponent<Renderer>().material;
font.TryAddCharacters(text.Select(c => (uint)c).ToArray());
Vector4[] uvs = text.Select(CharToUV).ToArray();
mat.SetVectorArray("_CharUVs", uvs);
mat.SetFloat("_Length", uvs.Length);
}
private Vector4 CharToUV(char c)
{
var character = font.characterLookupTable[c];
var rect = character.glyph.glyphRect;
var metrics = character.glyph.metrics;
return new Vector4(
1.0f * rect.x / font.atlasWidth, 1.0f * rect.y / font.atlasHeight,
1.0f * rect.width / font.atlasWidth,
1.0f * rect.height / font.atlasHeight
);
}
因为主相机不能在自己的 OnPreRender 里调用 RenderWithShader,所以另准备一个专门渲G图的相机lowResCamera,和主相机保持参数一致。 主相机的 OnPreRender:
private void OnPreRender()
{
lowResCamera.enabled = true;
lowResCamera.CopyFrom(camera);
lowResCamera.targetTexture = lowResRT; // 低分辨率的 G 图
lowResCamera.RenderWithShader(charShader, "RenderType");
lowResCamera.enabled = false;
}
RenderWithShader 渲染时,会使用到之前传给材质球的参数 CharUVs 和 Length。虽然材质原本的PBR或者Lamber之类的Shader渲染并不需要这两个参数,但是替代Shader会用到。
顺便说原图中使用的是 Unity 默认的 Standard Shader,这套方法不对原本的渲染方式有要求,也不需要修改原 Shader。
charShader 的核心部分,取屏幕坐标,计算余数,写入对应的字符 uv rect: 注意这里渲染的是G图,所以一个像素对应一个字。
float4 _CharUVs[10];
int _Length;
v2f vert (appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.screenPos = ComputeScreenPos(o.vertex);
return o;
}
fixed4 frag(v2f i) : SV_Target
{
float4 screenPos = i.screenPos / i.screenPos.w;
int2 coord = screenPos.xy * _ScreenParams.xy;
int index = coord.x + coord.y;
index = fmod(index, _Length);
return _CharUVs[index];
}
这样我们就得到了所需的G图。
PS:这里没有考虑字体的基线偏移,所以最后出来的效果不太整齐。
问题2:实现 f(G,E)
解答2:在后处理 Shader 中,计算 f(G,E),做一个有点复杂的 uv 换算:
_CharSize 等于字的大小,目前是 20。
_MaskTex 是前文所述的 G 图,_FontTex 是 TMP 的字体贴图。
fixed4 mask = tex2D(_MaskTex, i.uv);
float2 pxInSquare = fmod(i.uv * _ScreenParams.xy, _CharSize);
float4 xywh = mask * _FontTex_TexelSize.zwzw;
float2 offset = (float2(_CharSize, _CharSize) - xywh.zw) * 0.5;
float2 pxInGlyph = pxInSquare - offset;
float2 pxInAtlas = pxInGlyph + xywh.xy;
float2 uvInAtlas = pxInAtlas * _FontTex_TexelSize.xy;
float textMask = tex2D(_FontTex, uvInAtlas).a;
最终得到的 textMask 就是 M 图的亮度值,用他乘以屏幕颜色:
fixed4 col = tex2D(_MainTex, i.uv); // 问题1
col.rgb *= textMask;
return col;
到此为止的渲染效果:
(图5-2)
仔细观察会发现一个问题:如果一个字正好处于物体边缘的位置,会出现一个字有两个物体上的颜色。这样渲出来的物体边界和字的边界不一致,场景看起来像是简单的蒙版叠加。
不好:
(图5-3)
我们想要的:
(图5-4)
那对于物体交接的位置该如何处理呢
观察“树”的上半部分,我们想要的是树冠的绿色。但是显然,如果按照正常的光栅化流程,这个位置没有树的像素存在,所以无法计算光照。所以退而求其次,我们取“树”这个字中间的像素颜色值给整个字的范围。这样就达到了我们想要的效果。
(图5-5)
这样做了之后,光照会在整个字的范围内平均化,颜色边界和字体对齐,整个场景显得更加“文字”。
PPS:如果上面不是采用后处理获得C,而是采用低分辨率渲染的方案,这里就不需要处理了。