动态 SDF 字体渲染方法

动态 SDF 字体 介绍

在 Unity 中, TextMeshPro 对文本使用有向距离场(Signed Distance Field, SDF) 算法,相比原本的 ttf 字体,使用了 SDF 的文本,在任意距离、缩放尺寸下,都能渲染出清晰的文本,而 ttf 则可能出现毛边,失真的情况,而且对一些文本效果:描边、阴影、外发光、内发光等,TextMeshPro 通过 Shader 实现,相比原生 Text 组件通过增加顶点偏移方式,渲染效果更好,效率也更高,NeoX 引擎中也内置了 SDF 字体支持。

字体渲染方式

BitmapFont

最简单的文本渲染方式是:点阵字体(Dot-matrix-fonts)也叫位图字体(Bitmap-fonts),即将用到的字符,预先输出到一张贴图中,使用的时候再找到对应的字符的 UV,再绘制文本。

这种方法的缺点也很明显:字符集、字体的样式、字号在输出完贴图后就固定了

TureType Font

另外一个就是使用 FreeType 加载矢量字体(TrueType)来渲染文本。

  • ttf:TrueType Font 是Apple公司和Microsoft公司共同推出的字体文件格式
  • otf:OpenType Font 是 TTF 的升级版,而 OTF 是采用的是 PostScript 曲线,支持 OpenType 高级特性的更高级字体。
  • ttc:TTC 就是几个 TTF 合成的字库,字库中的字体大部分字都一样,共享笔画数据,个别字符有差异。

字体文件中存放的是每个字符绘制的样条曲线控制点,可以使用Glyph Inspector(在线字形查看器)来查看对应 ttf 文件中字符的信息:

其中: contours 中每个 contour 都是首尾闭合的轮廓,这里 g 有两个轮廓组成(最外层的边缘,以及中间空心的 O 轮廓)。蓝色点表示边缘上的点,红色的点是样条曲线的控制点 1

  • 一红一蓝:绘制 2 次贝塞尔曲线
  • 两蓝:绘制线段
  • 两红:两个控制点的连线 与 曲线相交处(数学上可推导,该交点就是两个控制点连线的中点),会有个 隐藏曲线点,分成 两个 2次-贝塞尔曲线;(就是下面 有小数 0 .5 的 终点)

下图是字符 B 通过控制点绘制的过程:

下面是一段文本的渲染结果,蓝色的线表示每行的 x,y 轴线。

渲染上面文本,对应字体会生成一张纹理,如下图所示:

不同字号,斜体、粗体的字模光栅化后都会存储在字体贴图中,大致原理跟 Bitmap Font 类似,只是字符的贴图是通过加载矢量字体,动态增加到贴图中。

下图是 FreeType 加载矢量字体中一些参数,左图是横向排版,右图是竖向排版

  • XY 轴:图中粗线是 XY 轴,其中远点是渲染该字符的局部原点(横向排版是 X 轴就是基线 baseline,竖向排版时,Y 轴是 baseline)
  • width,height:是对应字符的长宽
  • bearingX,bearingY:是字符渲染时,相对原点的偏移量
  • advance:步进宽度,表示两个相邻字符之间的距离

渲染时,需要根据文本字号,将 ttf 中的字符光栅化成对应的贴图:

左边是字体文件中的样条曲线,中间是不带抗锯齿光栅化结果,右边是带抗锯齿光栅化结果

光栅化的过程可以参考 game101 光栅化与抗锯齿

下图展示了光栅化的过程:上图是不带抗锯齿的版本,直接判断像素中心点是不是在三角形内
下图是抗锯齿版本,根据实际像素面积占比来计算颜色值(面积计算非常复杂,因此实际应用时会采用 MSAA,即将像素点拆分成四个小区域,分别判断这个四个小区域是不是在三角形内,来计算像素点的颜色占比)

SDF font

在贴图里面,不再存储纹理的像素数据,而是存储每一个点到边缘的距离:

这是字符 a 距离图,红色点表示边缘上的点,内部的像素点到边缘的最近距离为负值,外部的像素点到边缘最近的距离为正值。

其中 字符 a 灰度图如下(灰度表示该像素到字符边缘的距离,下面的图是距离标准化后的结果):

渲染时,采样贴图,将小于 0.5 的部分设置透明,即可还原最终的文本,下图是 DistanceMark 变化时 [0-1] 的渲染情况

Shader "Custom/SDF_Base"
{
    Properties
    {
        _MainTex("Texture", 2D) = "black" {}
        _DistanceMark("Distance Mark", Range(0,1)) = 0.5
        _SmoothDelta("Smooth Delta", Range(0,0.02)) = 0.5
    }


    fixed4 frag(v2f i) : SV_Target
    {
        fixed4 col;
        fixed4 sdf = tex2D(_MainTex, i.uv);
        float distance = sdf.a;
        col.a = smoothstep(_DistanceMark - _SmoothDelta, _DistanceMark + _SmoothDelta, distance);
        col.rgb = lerp(fixed3(0,0,0), fixed3(1,1,1), col.a);
        return col;
    }
}

smooth_func:用法

SDF 生成算法

生成 SDF 贴图的算法有很多包含:8SSEDT(8-point Signed Sequential Euclidean Distance Transform)应该是综合速度与错误率性价比最高的。另外可选的方案还有Chamfer3x3 DT(错误率稍高,速度稍快)或者4SSEDT(速度很快,错误率偏高)。

二值化算法

图形区域值为 1,图形区域外颜色值为 0,对于区域内的像素点,最近距离就是找到一个值为 0 的像素点,并且距离最近,区域外的类似。

  • 暴力法:直接遍历整个图片(Width * Height),在像素附近(N*M)的区域内,找到该像素最近的边缘距离,复杂度(O(width * height * N * M)
find_range = 5
for i in range(pix_width):
    for j in range(pix_height):
        left = max(0, i - find_range)
        right = min(pix_width, i + find_range)
        top = max(0, j - find_range)
        bottom = min(pix_height, j + find_range)

        is_inside = (new_pic[i, j] == 1)

        if dist_array[i, j] == 0:
            continue

        dist_array[i, j] = -max_int if is_inside else max_int

        for i1 in range(left, right):
            for j1 in range(top, bottom):
                if is_inside:
                    if dist_array[i1, j1] == 0:
                        dist = -math.sqrt((i1 - i) ** 2 + (j1 - j) ** 2)
                        dist_array[i, j] = max(dist_array[i, j], dist)
                else:
                    if dist_array[i1, j1] == 0:
                        dist = math.sqrt((i1 - i) ** 2 + (j1 - j) ** 2)
                        dist_array[i, j] = min(dist_array[i, j], dist)
  • 8SSEDT 算法
    8SSEDT 的核心思想是:计算某一个像素的最近距离,可以通过它附近八个方向的邻近点来计算,$x_i, y_j$ 表示当前像素最近的目标点的偏移量

EDT:求解欧拉距离,两点距离 $\sqrt{(x_1 - x_2)2+(y_1-y_2)2}$
-1, 0)表示左边像素就是目标点
(1, 1) 表示右下角的像素点就是目标点

如上图所示,$x_2, y_2$ 到最近点的偏移值为 $(0, 2)$,则可以得出当前点最近距离为斜边长,其他7个方向类似,求出最小距离。

事实上,对八个方向的遍历分为两个 PASS(为了确保对应方向上邻居的值已经计算完毕)

  • PASS0:从左上角开始遍历,逐行遍历,每次计算左上方的四个方向
  • PASS1:从右下角开始遍历,逐行遍历,每次计算右下方的四个方向

上图表示 8SSEDT,4SSEDT,以及SWDT 的扫描方式,图中的 mask 就一个 PASS,8SSEDT扫描方式会分为两个 PASS 进行(左上角 PASS0, 右下角 PASS1, 打点的方块是当前计算点,周围的黑色方块表示当前 PASS 需要计算的临近像素),最右图是 SEDT 算法的扫描方式,在 8SSEDT 基础上多了mask 2 和 3(在PASS0 做完后增加 PASS2 对该行再扫描一次),但是为了要求得正确的欧几里得距离,这两步必须的,没有这两步会导致斜线方向上的距离计算出现误差,详细可以参考论文链接

并且会使用两个通道 Mask,分别计算物体内到目标点距离,跟物体外到目标点的距离,每个 Mask 初始化时,会根据当前图片的灰度值,转成二值化图(灰度大于128 表示物体内,小于128 的丢弃),并初始化对应的 Mask,举个例子,下面是一个目标图,黑色表示物体内,白色表示物体外,初始化两个 Mask 如下图所示:

Mask1 经过一次遍历后结果如下(两次遍历结合起来,就能求出所有像素当目标点的最短距离)

最后将两次计算结果相减,即可得出最终结果 $Mask_1 - Mask_0$。详细代码

struct Point
{
    int dx, dy;

    int DistSq() const { return dx*dx + dy*dy; }
};

struct Grid
{
    Point grid[HEIGHT][WIDTH];
};


/// 根据灰度继续二值化,并初始化两个 Mask
if ( g < 128 )
{
    // inside = Point(0, 0)
    // empty = Point(99999999, 99999999)
    Put( grid1, x, y, inside );
    Put( grid2, x, y, empty );
} else {
    Put( grid2, x, y, inside );
    Put( grid1, x, y, empty );
}

// Generate the SDF.
GenerateSDF( grid1 );
GenerateSDF( grid2 );

// 比较当前距离跟邻居距离
void Compare( Grid &g, Point &p, int x, int y, int offsetx, int offsety )
{
    Point other = Get( g, x+offsetx, y+offsety );
    other.dx += offsetx;
    other.dy += offsety;

    if (other.DistSq() < p.DistSq())
        p = other;
}

void GenerateSDF( Grid &g )
{
    // Pass 0
    for (int y=0;y<HEIGHT;y++)
    {
        for (int x=0;x<WIDTH;x++)
        {
            Point p = Get( g, x, y );
            // 左  上  左上  右上 方向
            Compare( g, p, x, y, -1,  0 );
            Compare( g, p, x, y,  0, -1 );
            Compare( g, p, x, y, -1, -1 );
            Compare( g, p, x, y,  1, -1 );
            Put( g, x, y, p );
        }

        for (int x=WIDTH-1;x>=0;x--)
        {
            Point p = Get( g, x, y );
            Compare( g, p, x, y, 1, 0 );
            Put( g, x, y, p );
        }
    }

    // Pass 1
    for (int y=HEIGHT-1;y>=0;y--)
    {
        for (int x=WIDTH-1;x>=0;x--)
        {
            Point p = Get( g, x, y );
            // 右  下 左下  右下
            Compare( g, p, x, y,  1,  0 );
            Compare( g, p, x, y,  0,  1 );
            Compare( g, p, x, y, -1,  1 );
            Compare( g, p, x, y,  1,  1 );
            Put( g, x, y, p );
        }

        for (int x=0;x<WIDTH;x++)
        {
            Point p = Get( g, x, y );
            Compare( g, p, x, y, -1, 0 );
            Put( g, x, y, p );
        }
    }
}

灰度图

直接使用二值化图片生成 SDF 在图像边缘会有一些误差,详细参看论文链接,如下图:

使用二值化图,则图 c 中 B 点在计算距离时,A,C 像素灰度不够(小于0.5),会被丢弃,最终计算的距离方向是虚线箭头所示,但是真实的距离方向应该是实线箭头。

因此,对于边缘的像素($0 <$ 灰度 $< 1 $)需要单独处理,按照论文里个方法,对边缘上的像素进行分类:

  • 边缘垂直或者平行穿过像素

$$d_f = 0.5 - a$$

其中:
$d_f$:距离
a:像素灰度值

  • 边缘斜着穿过像素
    如下图(边缘的斜率可能不一样,但是都可以通过下面的图做旋转得到类似的结果)

灰色的地方表示目标像素灰度

首先定义几个常量:
$a_1$: 边缘穿过像素点左边的区域,并且经过像素最边缘的点
$a_2$: 中间区域

由上图左(1) 跟 左(2) 可求出下面几个常量:

$$\begin{align}
a_1 &= tan \varphi = \frac{g_y}{g_x} \
a_2 &= 1 - 2a_1 \
d_1 &= sin \varphi = g_y \
d_2 &= \frac{1}{\sqrt{2}}sin (\frac{\pi}{4} - \varphi) = \frac{1}{\sqrt{2}}sin (\frac{\pi}{4} - arcsin(g_y))
\end{align}
$$

其中单位向量 $\vec{g} = (g_x, g_y) = (cos \varphi, sin \varphi)$

则斜着穿过像素的情况有如下几种

  • $a < a_1$
  • $a_1 \leq a < a_1+a_2$
  • $a_1+a_2 \leq a < 1$

直接引用论文中的结论:

$$d_f =
\begin{cases}
\frac{(g_x + g_y)}{2} - \sqrt{2g_xg_ya} & 0 \leq a \leq a_1 \
(0.5 - a)g_x & a_1 \leq a \leq 1-a_1 \
-\frac{(g_x + g_y)}{2} - \sqrt{2g_xg_y(1-a)} & 1-a_1 \leq a \leq 1
\end{cases}
$$

梯度算子

最终我们需要求出 $\vec{g}$ 就可以得出最终结果 $d_f$,而 $\vec{g}$ 是像素边缘的梯度,论文里没有说怎么求,但是图像处理中,提供了多种梯度算子来计算边缘梯度。首先介绍下梯度:

一维连续数集上的函数的斜率公式:

$$f’(x) = f(x + \Delta x) - f(x)$$

二维连续数集上函数偏导数:

$$
\begin{aligned}
\frac{\partial f(x, y)}{\partial x} &= f(x + \Delta x, y) - f(x, y) \
\frac{\partial f(x, y)}{\partial y} &= f(x, y + \Delta y) - f(x, y) \
\end{aligned}
$$

对于图像来说,是一个二维的离线型数集,因此推广二维连续型求函数偏导的方法,来求图像的偏导数,即在 $(x,y)$ 处的最大变化率,也就是梯度。

$$\begin{aligned}
g_x &= \frac{\partial f(x, y)}{\partial x} = f(x + 1, y) - f(x,y) \
g_y &= \frac{\partial f(x, y)}{\partial x} = f(x, y + 1) - f(x,y) \
\end{aligned}
$$

把图片取像素点值的操作当成函数 $f(x,y)$,$\Delta$ 量为整数,且最小变化量为 1 个像素点

因此

$$\nabla f \equiv grad(f) = [g_x, g_y]^T = \left[\frac{\partial f}{\partial x}, \frac{\partial f}{\partial y} \right]^T$$

最后得出的模板如下:

上面是考虑水平跟竖直方向上的梯度

Roberts 算子

对角线方向的梯度:

$$\begin{aligned}
g_x &= \frac{\partial f(x, y)}{\partial x} = f(x + 1, y + 1) - f(x, y) \
g_y &= \frac{\partial f(x, y)}{\partial x} = f(x + 1, y) - f(x, y + 1) \
\end{aligned}
$$

3*3 模板

2*2 大小的模板在概念上很简单,但是他们对于用关于中心店对称的模板来计算边缘方向时,不是很有用,因此一般会使用 3 * 3 模板

  • Prewitt 算子
    水平竖直方向以及对角线方向的 $G_x$, $G_y$
  • Sobel 算子
  • Isotropic 算子

$$G_x = \left[
\begin{matrix}
-1 \quad 0 \quad 1 \
-\sqrt{2} \quad 0 \quad \sqrt{2} \
-1 \quad 0 \quad 1
\end{matrix} \
\right] * A \qquad G_y = \left[
\begin{matrix}
-1 \quad -\sqrt{2} \quad -1\
0 \quad 0 \quad 0\
1 \quad \sqrt{2} \quad 1
\end{matrix}
\right] * A$$

如果图片为 A
$$A=\left[
\begin{matrix}
P_1 \quad P_2 \quad P_3\
P_4 \quad P_5 \quad P_6\
P_7 \quad P_8 \quad P_9
\end{matrix}
\right]$$

$$G=\sqrt{G_x^2 + G_y^2}$$

$$
G_x = P_3-P_1+\sqrt{2}(P_6-P_4) + P_9 - P_7
$$
$$
G_y = P_7-P_1+\sqrt{2}(P_8-P_2) + P_9 - P_3
$$
$$
\quad\
g_x = \frac{G_x}{G}\
\quad\
g_y = \frac{G_y}{G}
$$

代码实现

下面给出完整的 python 代码的实现

import math
import numpy as np
from PIL import Image

def color_2_gray(color):
    r, g, b = color[0], color[1], color[2]
    gray = 0.2989 * r + 0.5870 * g + 0.1140 * b
    return gray / 255.0

## 线性插值方法这里将一张 1024 * 1024 的大图,插值成 32 * 32,灰度图
## 然后再计算 SDF 距离
def bilinear_interpolation(image, out_width, out_height, corner_align = True):
    width, height = image.width, image.height
    output_image = np.zeros((out_height, out_width))

    scale_x_corner = float(width - 1) / (out_width - 1)
    scale_y_corner = float(height - 1) / (out_height - 1)

    scale_x = float(width) / out_width
    scale_y = float(height) / out_height

    for out_x in range(out_width):
        for out_y in range(out_height):
            if corner_align:
                x = out_x * scale_x_corner
                y = out_y * scale_y_corner
            else:
                x = (out_x + 0.5) * scale_x - 0.5
                y = (out_y + 0.5) * scale_y - 0.5
                x = np.clip(x, 0, width - 1)
                y = np.clip(y, 0, height - 1)

            x0, y0 = int(x), int(y)
            x1, y1 = x0 + 1, y0 + 1

            if x0 == width - 1:
                x0 = width - 2
                x1 = width - 1
            if y0 == height - 1:
                y0 = height - 2
                y1 = height - 1

            xd = x - x0
            yd = y - y0
            p00 = color_2_gray(image.getpixel((x0, y0)))
            p01 = color_2_gray(image.getpixel((x1, y0)))
            p10 = color_2_gray(image.getpixel((x0, y1)))
            p11 = color_2_gray(image.getpixel((x1, y1)))

            x0y = p01 * xd + p00 * (1 - xd)
            x1y = p11 * xd + p10 * (1 - xd)

            value = x1y * yd + x0y * (1 - yd)
            output_image[out_y, out_x] = 1 - value

    return output_image

class Point(object):
    def __init__(self):
        self.alpha = 0
        self.gx = 0
        self.gy = 0
        self.dx = 0
        self.dy = 0
        self.df = 0
        self.di = 0
        self.distance = 0

class OctSSEDT(object):
    def __init__(self):
        pass


    ## img 是一张 1024 * 1024 的字体图
    def calc3_3AAEDT(self, img, pix_per_pix):
        width = img.width
        height = img.height

        out_width = int(width / pix_per_pix)
        out_height = int(height / pix_per_pix)
        out_img = bilinear_interpolation(img, out_width, out_height)
        
        value_min = out_img.min()
        for i in range(out_width):
            out_img[i, 0] = value_min
            out_img[i, out_width - 1] = value_min

        for i in range(out_height):
            out_img[0, i] = value_min
            out_img[out_height - 1, i] = value_min

        value_max = out_img.max()
        value_min = out_img.min()
        for i in range(out_height):
            for j in range(out_width):
                color = out_img[i, j]
                color = (color - value_min) / (value_max - value_min)
                if color < 1e-5:
                    color = 0

                if color > 0.99999:
                    color = 1

                out_img[i, j] = color

        out_dist = {}
        in_dist = {}

        self.img = out_img
        self.generate_sdf(in_dist, 0)
        self.generate_sdf(out_dist, 1)

        scale = 255 / ((5 + 1) * 2) * 2
        for j in range(out_height):
            for i in range(out_width):
                p0 = in_dist[(i, j)]
                p1 = out_dist[(i, j)]

                d0 = p0.distance
                d1 = p1.distance
                df0 = p0.df
                df1 = p1.df

                # out_img[i, j] = math.sqrt(p1.di) - math.sqrt(p0.di)

                if d0 < d1:
                    d1 =  math.sqrt(p1.di) + p1.df
                    d = max(0, min(127.5, d1 * scale))
                    out_img[i, j] = (127.5 - d + 0.5) / 255

                else:
                    d0 = math.sqrt(p0.di) + p0.df
                    d = max(0, min(127.5, d0 * scale))
                    out_img[i, j] = (127.5 + d + 0.5) / 255

        return out_img

    ## 应用 Isotropic 算子
    def calc_edge_gradient(self, index_x, index_y, point):
        img = self.img
        width, height = img.shape[0], img.shape[1]
        gx = 0
        gy = 0

        sqrt2 = 1.41421356
        gxy_offset = [
            (-1, -1), (0, -1), (1, -1),
            (-1,  0), (0,  0), (1,  0),
            (-1,  1), (0,  1), (1,  1),
        ]
        gx_matrix = [
            -1, 0, 1,
            -sqrt2, 0, sqrt2,
            -1, 0, 1,
        ]
        gy_matrix = [
            -1, -sqrt2, -1,
            0, 0, 0,
            1, sqrt2, 1,
        ]
        for i in range(9):
            offset = gxy_offset[i]
            x = index_x + offset[0]
            y = index_y + offset[1]
            if x < 0 or x >= width or y < 0 or y >= height:
                continue

            img_value = img[x, y]
            gx_m = gx_matrix[i]
            gy_m = gy_matrix[i]
            gx += gx_m * img_value
            gy += gy_m * img_value

        g = math.sqrt(gx * gx + gy * gy)
        if g > 0:
            gx /= g
            gy /= g
            point.gx = gx
            point.gy = gy

    ## 计算边缘像素的距离(论文中的方法)
    def calcEdgeDistance(self, gx, gy, a):
        img = self.img
        width, height = img.shape[0], img.shape[1]
        df = 0
        if gx == 0 or gy == 0:
            return 0.5 - a
        
        g = math.sqrt(gx * gx + gy * gy)
        gx = abs(gx / g)
        gy = abs(gy / g)

        if gx < gy:
            t = gx
            gx = gy
            gy = t

        a1 = gy / gx
        if a >= 0 and a <= a1:
            df = (gx + gy) / 2 - math.sqrt(2 * gx * gy * a)
        elif a <= 1 - a1:
            df = (0.5 - a) * gx
        else:
            df = -(gx + gy) / 2 + math.sqrt(2 * gx * gy * (1 - a))

        return df

    def compare_dist(self, dist, point, x, y, offset_x, offset_y):
        img = self.img
        width, height = img.shape[0], img.shape[1]
        width, height = img.shape[0], img.shape[1]
        maxDistance = width * width + height * height
        if x + offset_x < 0 or x + offset_x >= width 
            or y + offset_y < 0 or y + offset_y >= height:
            return

        other = dist[(x + offset_x, y + offset_y)]
        if other.distance == maxDistance:
            return
        
        dx = other.dx + offset_x
        dy = other.dy + offset_y
        alpha = dist[x + dx, y + dy].alpha
        df = self.calcEdgeDistance(dx, dy, alpha)
        di = dx * dx + dy * dy
        distance = di + df
        if distance < point.distance:
            point.distance = distance
            point.dx = dx
            point.dy = dy
            point.df = df
            point.di = di

    ## 8ssedt
    def generate_sdf(self, dist, mask = 0):
        img = self.img
        width, height = img.shape[0], img.shape[1]
        maxDistance = width * width + height * height

        for j in range(height):
            for i in range(width):
                color = img[i, j]
                point = Point()
                point.alpha = color if mask == 1 else 1 - color
                dist[(i, j)] = point

                if point.alpha > 0.001 and point.alpha < 1:
                    self.calc_edge_gradient(i, j, point)
                    df = self.calcEdgeDistance(point.gx, point.gy, point.alpha)
                    point.dx = 0
                    point.dy = 0
                    point.df = df
                    point.di = 0
                    point.distance = df
                    continue

                elif point.alpha == 0:
                    point.df = 0
                    point.di = maxDistance
                    point.distance = maxDistance
                elif point.alpha == 1:
                    point.dx = 0
                    point.dy = 0
                    point.df = 0
                    point.di = 0
                    point.distance = 0
                    continue

                self.compare_dist(dist, point, i, j, 0, -1)
                self.compare_dist(dist, point, i, j, -1, 0)
                self.compare_dist(dist, point, i, j, -1, -1)
                self.compare_dist(dist, point, i, j, 1, -1)

        for i in range(width - 1, -1, -1):
            for j in range(height - 1, -1, -1):
                point = dist[(i, j)]
                
                if (point.alpha > 0 and point.alpha < 1) or point.distance == 0:
                    continue

                self.compare_dist(dist, point, i, j, 0, 1)
                self.compare_dist(dist, point, i, j, 1, 0)
                self.compare_dist(dist, point, i, j, 1, 1)
                self.compare_dist(dist, point, i, j, -1, 1)

参考

1.字体:从 TTF 到 位图

2.关于TextMeshPro

3. 动态 SDF 字体实现要点

4.UE4 Signed Distance Fields:符号距离场(一)

5.我所理解的SDF

6.KlayGE游戏引擎