Unity3D 使用矩形图片拉伸填充梯形

Unity3D 使用矩形图片拉伸填充梯形

一般来说,Unity3D 中出现的梯形,常常是由透视相机根据透视原理渲染出来的。但如果要在 Unity3D 中得到一个没有透视效果的梯形(即仅对矩形的顶边/底边进行伸长/缩短得到的梯形),反倒不是一件轻松的事情。博主在开发一个 Unity3D 项目的时候,正好遇到的这样的需求,一番搜索无果后,只好自行尝试解决这一问题。下面是中间一些过程的笔记。

Mesh

Meshes make up a large part of your 3D worlds.
Unity supports triangulated or Quadrangulated polygon meshes.

任何复杂的图形(无论是二维的还是三维的),均可以(完全地或近似地)划分为若干个三角形。

如果需要在 Unity3D 中自行绘制一个梯形,那么需要用到 Mesh (网格)

Mesh 的基本单位是三角形 (triangle) 。通过给定 Mesh 的各个顶点 (vertices) ,然后指出这个 Mesh 被划分而成的若干个三角形各自的三个顶点(这些三角形的顶点均存在于 Mesh 的顶点集合中),这就定义了一个 Mesh。

多边形及其划分的三角形

如果通过搜索引擎搜索类似”Unity3D 画多边形“的关键词,得到的说明大抵如此。

进一步地,如果要给这个多边形贴上图,可以通过在 Mesh Renderer 中指定 Material (材质) 以及 UV 坐标来做到。

Material 决定一个表面如何被渲染。Material 使用一个指定的 Shader (着色器) ,以及一个或多个的 Texture (纹理) ,再加上若干参数进行渲染。UV 坐标决定顶点对应的纹理偏移,即顶点对应贴图的哪个位置。

下面我们将讨论平行四边形以及梯形 Mesh 的贴图。

矩形与平行四边形

平行四边形 (parallelogram) 是两组对边分别平行的四边形。梯形 (trapezium) 是只有一组对边平行的四边形。矩形是内角均为 90° 的平行四边形。

在 Unity3D 中,通过 Mesh 可以很容易地将一个矩形的 Texture 贴到矩形或平行四边形上。如图,将矩形的 Texture 保持一个方向上的高度不变(当然,改变高度也是可以的,实际上只是进行了垂直/水平拉伸),然后向另一个方向倾斜(也就是斜向拉伸),就得到了一个平行四边形。

将矩形变换为平行四边形

在实现上,通过指定被贴到 Mesh 上的 Texture,以及 Mesh 的各个顶点对应的 UV 坐标,就可以将 Texture 贴到 Mesh 上(Shader 选用绘制二维 Sprite 的 Sprites/Default 即可)。UV 坐标的数量与顶点的数量相等,一一对应。各个 UV 坐标分别表示该坐标的相应顶点对应到 Texture 的何处。平行四边形的贴图,实际上就是矩形的 Texture 在不同的行(或者列)有不同距离大小的偏移实现的。

顶点与 UV
改变 UV,只将 Texture 的左半部分贴到 Mesh 上

实现代码如下:(需要给 ParallelogramMesh 指定一个 Texture)

using UnityEngine;

[RequireComponent(typeof(MeshFilter))]
[RequireComponent(typeof(MeshRenderer))]
public class ParallelogramMesh : MonoBehaviour
{
    public Texture2D texture;
    public int width = 512;
    public int height = 512;
    public int xOffset = 256;

    private Mesh mesh;
    private Vector3[] vertices;

    void Start()
    {
        MeshFilter mf = GetComponent<MeshFilter>();
        MeshRenderer mr = GetComponent<MeshRenderer>();

        mesh = new Mesh();
        mf.mesh = mesh;

        // set vertices
        vertices = new Vector3[4];

        UpdateVertices();

        // set triangles
        int[] tri = new int[6];

        tri[0] = 0;
        tri[1] = 2;
        tri[2] = 1;

        tri[3] = 2;
        tri[4] = 3;
        tri[5] = 1;

        mesh.triangles = tri;

        // set normals
        Vector3[] normals = new Vector3[4];

        normals[0] = -Vector3.forward;
        normals[1] = -Vector3.forward;
        normals[2] = -Vector3.forward;
        normals[3] = -Vector3.forward;

        mesh.normals = normals;

        // set UVs
        Vector2[] uv = new Vector2[4];

        uv[0] = new Vector2(0, 0);
        uv[1] = new Vector2(1, 0);
        uv[2] = new Vector2(0, 1);
        uv[3] = new Vector2(1, 1);

        mesh.uv = uv;

        // set material
        Material material = new Material(Shader.Find("Sprites/Default"));
        material.mainTexture = texture;
        mr.material = material;
    }

    void Update()
    {
        UpdateVertices();
    }

    void UpdateVertices()
    {
        vertices[0] = new Vector3(0, 0, 0); // bottom left
        vertices[1] = new Vector3(width, 0, 0); // bottom right
        vertices[2] = new Vector3(xOffset, height, 0); // top left
        vertices[3] = new Vector3(xOffset + width, height, 0); // top right

        mesh.vertices = vertices;
    }
}

可以在 Unity Editor 中,通过改变 ParallelogramMesh 脚本的 Width , Height , X Offset 参数,观察改变宽度、高度和倾斜偏移参数后,图形的变化情况。

Unity Editor - ParallelogramMesh Script

梯形

但在平行四边形的基础上,将其中一条边的长度改变,情况就有所不同了。

首先,我们改动前面的绘制平行四边形的相关代码,使得 4 个顶点形成一个等腰梯形(前面已经证实,水平或垂直方向的倾斜是可以正常绘制的,而任意梯形均可以通过顶边和底边长分别相等的等腰梯形进行倾斜获得,因此只需研究等腰梯形的情况即可),其余参数保持不变。

public int widthTop = 256;
public int widthBottom = 512;

void UpdateVertices()
{
    vertices[0] = new Vector3(0, 0, 0); // bottom left
    vertices[1] = new Vector3(widthBottom, 0, 0); // bottom right
    vertices[2] = new Vector3(xOffset + (widthBottom - widthTop) / 2, height, 0); // top left
    vertices[3] = new Vector3(xOffset + widthBottom / 2 + widthTop / 2, height, 0); // top right

    mesh.vertices = vertices;
}

为了便于观察,下面给出一个比较典型的材质图片(包含水平线、间距相等的垂直线以及一条对角线),在后面的分析中将用到这张图。

校正参考图

将梯形设定为顶边长:底边长 = 1:3,我们并没能直接得到一个正确绘制的梯形。如图,图形在对角线(准确地说是 Mesh 的两个三角形的公共边)上出现了泾渭分明的转折,对角线两侧分为了两部分。

梯形:预期效果与实际效果

两个三角形?

前面提到,复杂的图形可以完全地或近似地划分为若干个三角形。表面上看,梯形是一个四边形,按说只需要 2 个三角形即可。这在梯形没有进行贴图的时候是正确的。但是观察上图左侧的”预期效果“图,可以发现原图中为直线的对角线,被拉伸为梯形后变为了曲线。

这给了我们一个提示。梯形与矩形和平行四边形不同的地方在于,梯形沿垂直方向上的宽度是线性变化的,而不是一个常数。而沿矩形对角线分割而成的两个三角形拼合以后,组成的图形只能是固定宽度的。

梯形的两个三角形

如图所示,”实际效果“图中的两个三角形的真实面目如此。在保持两个三角形内部不变的情况下,无论我们如何进行拉伸,倾斜等,都不能得到预期效果中的那个梯形。

要解决这个问题,有以下两个思路:

  1. 微元:增加三角形的数量。以上述梯形为例,平行于底边分割为尽可能多的梯形,这样各个小梯形都近似一个矩形(矩形的绘制是没有问题的),再将各个矩形分别分为两个三角形即可。
  2. 校正:在渲染时进行修正,使得 Texture 被绘制到 Mesh 中的正确的位置上。

使用 Shader 进行校正

微元法虽然简单粗暴,但是会增加许多不必要的开销(计算网格顶点等),观察发现实际效果图中的直线的偏移规律是线性的,想到可以使用 Shader,在 frag 函数 (片元函数) 中通过数学计算进行校正。

frag 函数的作用:返回模型对应屏幕上的每一个像素的颜色值

简单来说,在 Sprites/Default shader 中,frag 函数根据传入的 uv 坐标,读取 Texture 中对应位置的像素颜色,将结果作为返回值。(暂不考虑 Color 等参数)下面的伪代码粗略地描述了这一原理。

Point p; 
// p.color: P 点呈现的颜色值
// p.uv: P 点对应 texture 的 uv 坐标
// p.vertex: P 点在屏幕上的坐标
Texture2D texture;

Color frag(Vector2 uv)
{
    return ColorOfTextureUV(texture, uv);
}

p.color = frag(p.uv);
Render(p.vertex, p.color);

但在梯形 Mesh 的绘制中,P 点的颜色值 p.color 并没有被渲染到预期位置 P 点上,而是渲染到了另外一个 Q 点 (对应的屏幕坐标为 p.vertex ) 。由于 frag 函数不能改变 P 点颜色最终被渲染到屏幕上的坐标 p.vertex,即 frag(p.uv) 的返回值必定被渲染到 Q 点(即 p.vertex 的位置)上,因此可以使用一个反向的思路:根据 p.uv 计算出 p.vertex(即 Q 点的坐标),进而用 Q 点的正确的 UV 坐标替换 p.uv,以得到 Q 点的正确颜色。

数学问题

接下来就是数学问题了。

相关计算

  1. 在实际效果图中建立一个直角坐标系(p.vertex 是直角坐标系的坐标),梯形则为 UV 坐标系(p.uv 是 UV 坐标系中的坐标)。
  2. 设梯形的顶边长和底边长的比为 k
  3. 为了方便计算,设梯形的底边长度为 1(这样梯形的顶边长度就为 k)。
  4. P点和Q点分别在蓝线和红线上移动。
  5. 观察推测得出以下规律:

    • P 点与 Q 点的纵坐标相等(从原图中白线在梯形中仍然平行于底边可知)
    • 红线的折点的纵坐标,与红线的端点在顶边或底边的相对位置呈线性关系。
    • P 点与 Q 点的距离是一个分段函数,两段均为与纵坐标相关的一次函数。
    • P 点与 Q 点距离的最大值,与红线的端点在顶边或底边的相对位置有关。
  6. 总结上述规律,只需要计算出 P 点与对应 Q 点的(水平)距离,即可得到 Q 点的 UV 坐标。

编写 Shader

可以在 Sprites/Default Shader 的基础上进行改动。在 GitHub 上找到 Sprites/Default.shader 的源码。frag 函数相关代码如下:

sampler2D _MainTex;
sampler2D _AlphaTex;
float _AlphaSplitEnabled;

fixed4 SampleSpriteTexture (float2 uv)
{
    fixed4 color = tex2D (_MainTex, uv);

#if UNITY_TEXTURE_ALPHASPLIT_ALLOWED
    if (_AlphaSplitEnabled)
        color.a = tex2D (_AlphaTex, uv).r;
#endif //UNITY_TEXTURE_ALPHASPLIT_ALLOWED

    return color;
}

fixed4 frag(v2f IN) : SV_Target
{
    fixed4 c = SampleSpriteTexture (IN.texcoord) * IN.color;
    c.rgb *= c.a;
    return c;
}

相关的计算过程如下:

// p.uv
float x0 = uv.x;
float y0 = uv.y;

// p.vertex
float xp = x0 * (1 - (1 - k) * y0) + 0.5 * (1 - k) * y0;
float yp = y0;

// 折点纵坐标
float y_ = 1 - x0;

// 在梯形内,纵坐标为 y_ 的平行线长度
// length_ == l + m + n
float length_ = (1 - (1 - k) * y_); 

float n = k * (1 - x0);
float l = x0 * (1 - (1 - k) * y_);
float m = length_ - l - n; // P、Q点间的最大距离

// Q 点的 vertex 坐标
float xq = xp + min(y0, y_) / y_ * m - max(0, y0 - y_) / (1 - y_) * m;

//换算为 Q 点的 uv 坐标
uv.x = (xq - 0.5 * (1 - k) * y0) / (1 - (1 - k) * y0);

化简

将上面的中间变量都代入最终结果的表达式,然后化简即可。

uv.x +=
    (
        min(uv.y, 1 - uv.x) * uv.x
        - max(0, uv.y - (1 - uv.x)) * (1 - uv.x)
    ) 
    * (1 - k) 
    / (1 - (1 - k) * uv.y)
;

成品 Shader

链接:Sprites/Trapezium.shader (注意设置 Edge Ratio 参数)

本文采用 署名-非商业性使用 4.0 国际许可协议 进行许可。
本文作者:KeNorizon
本文链接:https://blog.kenorizon.cn/note/unity3d-fit-texture-into-trapezium.html

评论

暂无

添加新评论