PreFound:08 - 动效与仿射

/ dousha99

如何让屏幕上元素的移动显得更「自然」?

图层系统

目前我们仍然是直接操作 SDL 的主材质,虽然方便,但是在后续实现一些效果的时候会比较棘手。比如我们即将开始研究的仿射变换需要针对整个画面进行操作,如果是纯色色块这种没有自己临时图层的生成器就比较难以实现。

所以,每个生成器都应渲染出一个自己的材质,然后最终再由主循环逐一地将每个生成器生成的中间材质复制到主材质上。

同时,虽然生成器仍然是操作一个具体的 SDL_Texture, 但是这个材质的大小可以随着具体的需要改变。自然的,每个材质要复制的位置也是可以不同的。所以,我们定义一个图层 Layer 为包含原点坐标、图层尺寸和一个可绘制材质的对象。

为了方便和 SDL 做交互,定义图层的原点始终对应材质的左上角,并定义全局的坐标系为右 +x 下 +y 坐标系。

coordinates.png

图 1 坐标系的定义

按道理来说,图层应该是有 Z 轴定义的。不过现在为了方便,暂时按照图层创建的次序定义 Z 轴顺序:越晚创建的图层具有越高的 Z 轴值。

图层变换算子

现在有了图层的概念之后,我们还需要想办法驱动这些图层。

我们目前设计的架构并不直接支持我们做有状态的渲染。从架构设计上,我们也不应该考虑做有状态的渲染:理想情况下,所有帧的渲染都仅与时间相关,这样即使是非常复杂的场景,也可以通过多机并行渲染来实现短时间内出结果。

所以,图层变换算子的输入也只有一个:当前相对于片段开始的时间。图层变换算子本身也必须是纯的,不能携带任何非常数参数。

这个重要限制在之后也会影响许多从原理上应该是「有状态」的变换设计。简要地来说,一旦我们遇到了严格有状态的变换,比如依赖于一些没有解析解的微分方程组的变换,那么这些变换会在外部预先计算好具体的值,然后作为数据加载给变换算子。变换算子仅负责查表,以及进行必要的插值。

位置变换

一般指定图层位移时,自然的方法是指定一个初始位置 x0\vec{x}_0、一个结束位置 x1\vec{x}_1,以及期望这段动画开始 t0t_0 和持续的时间 dd

不妨考虑函数 T(t)x \mathrm{T}(t) \mapsto \vec{x} 的输出是时刻 tt 时,图层所在的位置。考虑最简单的线性插值法,显然的,有:

T(t)={undefinedt<t0x0+(tt0d)(x1x0)t0tt0+dundefinedt0+d<t \mathrm{T}(t) = \begin{cases}\mathrm{undefined} & t < t_0 \\ \vec{x}_0 + (\frac{t - t_0}{d})(\vec{x}_1 - \vec{x}_0) & t_0 \le t \le t_0 + d \\ \mathrm{undefined} & t_0 + d < t \end{cases}

插值函数

更一般的,设 I(x)[0,1] \mathrm{I}(x) \mapsto \left[0, 1\right] , 且 x[0,1] x \in \left[0, 1\right] 为一个插值函数,那么图层位置变换函数可以定义为:

T(t)={undefinedt<t0x0+I(tt0d)(x1x0)t0tt0+dundefinedt0+d<t \mathrm{T}(t) = \begin{cases}\mathrm{undefined} & t < t_0 \\ \vec{x}_0 + I(\frac{t - t_0}{d})(\vec{x}_1 - \vec{x}_0) & t_0 \le t \le t_0 + d \\ \mathrm{undefined} & t_0 + d < t \end{cases}

换成人话说就是:我们可以只关心设计一个平滑的、输入输出都是从 0 到 1 的函数作为插值函数,然后把它代换进去就可以了。当然,如果要实现回弹效果的话,输出的值是可以大于 1 或者小于 0 的。

是时候写点看起来比较自然的动效了。

现实世界中,一个物体不会突然开始移动,也不会突然停止移动。它应该缓慢加速,再缓慢停止。我们不妨考虑恒外力下从静止开始的运动,此时位移的平方和时间成正比;在恒外力下减速同理。稍微整理一下,可以写出一个符合要求的分段函数:

I(t)={2t20t0.51(2(x1))20.5<t1 I(t) = \begin{cases} 2t^2 & 0 \le t \le 0.5 \\ 1 - (\sqrt{2}(x - 1))^2 & 0.5 < t \le 1 \end{cases}

我们也可以考虑使用三角函数来实现缓入缓出的效果:

I(t)=12(1cos(πt)) I(t) = \frac{1}{2} (1 - \mathrm{cos}(\pi{}t))

三次贝塞尔曲线

一个在设计领域内更常见的插值函数是通过三次贝塞尔曲线生成:

B(t)=(1t)3P0+3(1t)2tP1+3(1t)t2P2+t3P3 \vec{B}(t) = (1 - t)^3\vec{P}_0 + 3(1 - t)^2t\vec{P}_1 + 3(1-t)t^2\vec{P}_2 + t^3\vec{P}_3

其中,P03 \vec{P}_{0 \dots 3} 是控制点。一般,P0=(0,0) \vec{P}_0 = (0, 0) , P3=(1,1) \vec{P}_3 = (1, 1) . P1 \vec{P}_1 P2 \vec{P}_2 是用户控制的两个点。

要保证这个贝塞尔曲线可以形成一个函数,则需要 P1 \vec{P}_1 P2 \vec{P}_2 的横轴坐标在 [0,1] \left[0, 1\right] 之间。

贝塞尔曲线本身的输出 B \vec{B} 是一个点,我们只取 yy 轴坐标作为输出即可。

仿射变换

当我们说向量 y \vec{y} 由向量 x \vec{x} 经过仿射变换得出时,我们指存在一个矩阵 A \mathrm{A} 和向量 b \vec{b} 满足 y \vec{y} x \vec{x} 关系:

y=Ax+b \vec{y} = \mathrm{A}\vec{x} + \vec{b}

换成人话说就是:通过仿射变换,可以自由地平移和拉伸图层,但拉伸之后的图层只能是一个平行四边形。是的,矩形是一种特殊的平行四边形。

在 SDL 中,仿射变换的 API 非常地符合直觉:这个变换是由图像左上角坐标、图像右上角坐标和图像左下角坐标共同定义的。你需要这个材质的这三个点出现在哪里,就可以直接指定到哪里。但是这也意味着,当我们需要做诸如从属关系的时候,需要通过这个信息手动反算出变换矩阵。

考虑到仿射变换涵盖了平移,故将其也设置为图层的属性之一。在最终层叠所有的图层的时候,就可以通过仿射变换 API 来层叠了:

SDL_SetRenderTarget(sdl.renderer(), sdl.texture());
SDL_SetRenderDrawColor(sdl.renderer(), 0, 0, 0, 0);
SDL_RenderClear(sdl.renderer());

for (const auto* layer : layers) {
    if (!layer->visible() || layer->texture() == nullptr) {
        continue;
    }

    SDL_SetTextureBlendMode(sdl.texture(), SDL_BLENDMODE_BLEND);
    SDL_RenderTextureAffine(sdl.renderer(),
                            layer->texture(),
                            nullptr,
                            layer->origin(),
                            layer->right(),
                            layer->down());
}

SDL_SetRenderTarget(sdl.renderer(), nullptr);

同样的,我们也可以设计一个仿射变换的算子。比如弹簧振荡效果:

If(t)=sin(fπt)fπt I_f(t) = \frac{\mathrm{sin}(f\pi{}t)}{f\pi{}t}

什么?你问为啥它是从左上角为原点缩放的?因为我写到这里的时候发现了一个很头大的事情:图层没有定义过计算原点,所以一旦算子改变了图层的左上角坐标,之后的计算就会叠加在新的坐标上了。而且,又由于算子本身又被设计为可以叠加,所以还不能在算子实例中保存原有的原点——万一用户确实需要它动呢?

也许变换矩阵确实是一种好文明。

自动变换计算

那么,能不能把移动和变换结合起来呢?比如,图层在横向移动的时候,会变得更「扁」,而且速度越快越「扁」。

当然可以!我们甚至可以这样定义:

由于速度越快越扁,所以我们需要取到插值函数的导数 I(t) I^\prime{}(t) 。好在目前我们设计的函数都是初等函数的组合,其导数很容易求出解析解。一个例外是三次贝塞尔曲线。遇到这种,我们只能取 t t 附近的值做差来近似求出变化量。

注意到,当我们求速度时,我们实际是在求一个复合函数的导数:v(t)=I(tt0d)=I(t)d v(t) = I^\prime{}(\frac{t - t_0}{d}) = \frac{I^\prime{}(t)}{d}

我们定义一个参数 z z 来限制形变,一般来说这个值会很小,比如 0.05 0.05

定义「压扁率」为 0 时表示无形变,为 1 时表示完全被压扁,则可以定义「压扁率函数」S(v(t))=11v(t)z+1 S(v(t)) = 1 - \frac{1}{|v(t) * z| + 1} ,这个定义方法可以保证速度无论多快,「压扁率」都不会达到 1. 同时可以定义出被压扁的边长度为 b(1S(v(t))) b (1 - S(v(t))) .

而「压扁」这个操作本身应该保持面积,即压扁的矩形的面积应该无形变的矩形的面积相同。则被伸长的边长度为 a1S(v(t)) \frac{a}{1 - S(v(t))} .

最后,解算变换以矩形中心进行!

虽然所有的所有这些操作在正经的非线编软件里应该都是可以做出来的,但亲自动手实现出来还是有那么一番别样的滋味。关键帧系统是一个好东西,不过现在还不太好搞。

下一步

有了图层的概念,我们就可以做一些色彩运算了——比如说正片叠底。

正在加载评论……

发表评论

您的评论将由管理员审核后方可公开显示。

Your comments will be submitted to a human moderator and will only be shown publicly after approval.