用 Unity 实现 3D 文字游戏

 

用 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,而是采用低分辨率渲染的方案,这里就不需要处理了。