很久没写博客了,之前一直想整理一些东西出来,不过把 repo clone 下来以后才发现自己的主题被设置为了 submodule 所以没有 push 上去……于是只有回到上海拿到旧电脑,把文件恢复了才能继续写东西。T^T

今天记录一下最近在做 Cocoon 的时候写的比较有意思的两个场景/渲染相关的小技巧。

1. 视差效果和编辑器内的视差预览

视差是在横版卷轴游戏里常用的一种绘制技巧。通过把背景层(或前景层)的东西在不同的速率滚动,以造成“场景有深度”的感觉。

在 Unity 里,实现视差滚动的方法通常有两种:

  1. 使用 shader,在 vertex shader 里对顶点位置进行位移。
  2. 使用脚本直接对滚动物体的 transform 进行操作。

我目前为止只在 Unity 里尝试过方法2(当然,1也不是很麻烦,但是可能要处理对象提前被 cull 掉了之类的问题),所以这里也暂且只讨论方法2。

视差的概念很简单,但实现起来却存在一些问题。最重要的问题莫过于:对于一个随视线移动的东西,我要如何在编辑阶段恰当的处理它?最粗暴的方法莫过于在编辑阶段让它处于初始位置,只在运行时进行滚动计算。然而这样让我的编辑流程变的很不直观。我需要在编辑器里调整滚动速率、相对位移等等很多的参数,却只能在Play Mode进行预览。

于是,我通过 [ExecuteInEditMode] 参数,实现了在运行时的 Parallax 预览。实现的重点是利用了 Camera.onPreCull 回调:

OnPreCull is called before a camera culls the scene.

Culling determines which objects are visible to the camera. OnPreCull is called just before this process. This message is sent to all scripts attached to the camera.

如果我们在这个阶段直接去修改 transform.position,那么 SpriteRenderer (或其他任何 Renderer)在后续发送 DrawCall 的时候就会以更新的位置进行渲染,这样视差本身就OK了。我们需要在 Update 里检查是否已经注册了回调,如果没有则注册它。

由于我们在编辑器里修改了 transform.position,所以对象的位置需要在我们的组件内用一个 originPos 额外存储,并基于它加上摄像机的位置进行位移。为了让参数调整更加符合直觉,我们让卷动速度正比于 originPosz 分量。

最后,有时候我们不需要在编辑器里进行预览,所以我们需要开关在编辑器里预览的选项,把这个作为一个菜单项实现就好了。

最后的代码,除去相对不重要的部分,大致如下:

[ExecuteInEditMode]
public class ParallaxLayer : MonoBehaviour {

    static bool parallexPreview;

    #if UNITY_EDITOR

    [MenuItem("Tools/Toggle Parallax Preview")]
    static void ToggleParallaxPreview() {
        parallexPreview = !parallexPreview;
    }

    #endif

    const float ZScaleFactor = 0.05f;

    public Vector3 localPosition;

    public bool customScale;

    [ShowIf("customScale")]
    public float customScaleFactor;


    bool initialized;

    void Start() {
        if (Application.isPlaying) {
            Camera.onPreCull += MyPreRender;
            initialized = true;
        }
    }

#if UNITY_EDITOR // 在编辑器里才需要在 Update() 回调注入 callback
    void Update() {
        if (!Application.isEditor) return;

        if (!initialized) {
            Camera.onPreCull += MyPreRender;
            initialized = true;
        }
        transform.position = localPosition;
    }
#endif

    void OnDisable() {
        initialized = false;
        Camera.onPreCull -= MyPreRender;
    }

    void MyPreRender(Camera cam) {
        if (Application.isPlaying || parallexPreview) { // 判断是否需要更新滚动位置
            Vector2 scaleFactor = ZScaleFactor * new Vector2(localPosition.z, localPosition.z);
            transform.position = localPosition + (Vector3) (Vector2.Scale(scaleFactor, cam.transform.position));
        }
    }
}

就这么简单。看看效果吧:

2. 摇动的草(Vertex Animation & Instancing)

摇动的草应该是一开始做 Vertex Animation 的时候最先会想到的一个题目。在做工厂这个场景的时候我们的美术也是早早的告诉我【想要摇动的草】,不过过了好几个月才回头来填这个坑呢。接下来从思路和工程方面分别讨论这个问题。

2.1. 实现思路

让我们来思考草晃动的时候会是什么效果。草的根部应该是固定在地面上,而越接近顶端的部分,受到的拉力越小,因此晃动的幅度也越大。因此,我们可以让顶点的横向位移 dx 作为草的顶点高度 h 的一个函数。草的晃动应该是来回周期性的,因此我们使用 sin 函数来实现对应:

dx = A * f(h) * sin(B * t)

这里的 A 是晃动最大幅度, B 是晃动速度, f(h) 则代表高度对幅度的影响函数,调整它会带来细微但有趣的效果。

  • 线性: f(h) = h

  • 平方: f(h) = h * h

可以看到,平方对应的函数在尖端位移会更多一些,看起来也更符合草的动态,所以我最终选择了平方函数。

2.2. 工程问题

2.2.1. 草的高度在哪里?

如果我们的贴图整块就是草,那么贴图uv的范围就是(0, 0) -> (1, 1),那么我们就可以很轻松的拿v分量来当顶点高度。然而,贴图很有可能只有部分被渲染或位于某张 atlas 中。因此,我们需要通过别的方法设置草的高度,比如说为 Mesh 设置一个新的 UV1 通道。这样的话,SpriteRenderer 显然是没法用了,只能自己搞 MeshRenderer

2.2.2. 网格不能是 Quad

假如我们要使用非线性的高度对应函数,显然我们不能只用四个顶点来渲染这个草。因为不管我们如何位移顶点,最终还是一个四边形。我们需要把四边形在垂直方向细分成多个小块。细分的越多,对应效果越好。我这里只细分了 5 块就能得到很好的效果。

2.2.3. 不同草的晃动区分

如果我们严格的按上面 dx = A * f(h) * sin(B * t) 这一函数来进行草的网格扭曲,你会发现,所有草完全在以相同的相位进行摆动——这显然不是我们想要的。让我们在位移计算中,给每个草加上一个独立的时间偏移:

dx = A * f(h) * sin(B * t + C)

这个 C 可以以 MaterialPropertyBlock 的形式设置每个示例的属性,再通过 MeshRenderer.SetPropertyBlock 传递给 MeshRenderer

2.2.4. 必要的优化:Instancing

做完上一步,我们已经可以得到一个很不错的效果了:

然而,如果这时候你打开 Stats 面板,会发现 DrawCall 暴增,每个草实例都成为了一个单独的 DrawCall。这是因为我们为每个实例设置了不同的属性。但通过 Unity 5.0+ 新增的 GPU Instancing 支持,可以使用图形 API 的 instancing 将这些 Drawcall 合并。

我们需要在 shader 中,用特殊的宏指明需要 instancing 的属性:

UNITY_INSTANCING_CBUFFER_START(Props)
    UNITY_DEFINE_INSTANCED_PROP(float, _TimeOffset);
UNITY_INSTANCING_CBUFFER_END

在访问时:

float timeOffset = UNITY_ACCESS_INSTANCED_PROP(_TimeOffset);

最后,在创建的 Material 中勾选 GPU Instancing 选项,就可以享受 Instancing 的福利了。

关于 Instancing 详细的使用方法可以参见官方文档 GPU Instancing,这里仅仅简单介绍一下~

3. Misc

好久没写博客了,机会难得也写写最近发生的事情吧~

最近在结束了学校一个月的课设之后,又回到了上海继续实习。在实习之余的空闲时间基本上一直在写 Cocoon。Cocoon 这个项目在 一波三折再一次变更玩法后,可以说终于快要看到了希望。我们现在完成了剧情大纲、整体的玩法设计,做了几个场景原型和怪物原型,感觉已经验证的部分玩起来基本都不错(不像以前,基本一直在怀疑人生)。希望能一直以这个势头下去,尽快做出 Demo。

现在最难受的一件事就是怕时间不够多。又要实习上班,又要做一个规模很大的 Side Project,还想学很多很多的新东西,还想玩很多游戏。要是一天有 72 个小时该多好 Σ(゚д゚;) 嘛总之希望能够早睡早起,早上的时间也利用起来就好啦,珍惜时间~

前段时间其实内心有些动摇,看着周边的同学都在找工作面试 blablabla,自己也在犹豫要不要再找找其他的机会(大家们都劝我去腾讯网易等等大厂看看)。

不过我果然不想去大公司啊啊啊啊!我也不想做王者荣耀阴阳师这样的辣鸡手游啊!!!只要这样的想法涌入脑海,感觉就没什么好纠结的了呢(笑)。

嗯,所以现在就是在基本确定要努力去转正的前提下,静下心来做项目学习东西的节奏。这样或许会失去一些其他人说的“珍贵的机会”也说不定,但我更倾向于认为这是在有效的利用现在的宝贵时间。

在大学剩下的最后几个月,努力的提升自己,做出很棒很棒的项目,然后顺利毕业,只要心无旁骛的朝着这个目标努力就好了。(啊要是能开自己的独立游戏工作室该多好啊)

Everything will turn out to be fine, because I’m still following my heart and trying to work out the best.

继续加油。 (。・ω・。)