«

古法手搓三维模型数据,顶点 UV 索引概念一文搞定!(vertices uv indices)

时间:2025-8-4 14:13     作者:独元殇     分类: WebGL


[toc]

introduce

工具地址:https://git.ccgxk.com/vtool/vtool.html

今天要通过一个我精心制作的网页工具,手把手教大家手搓一个 六棱柱。让大家(尤其是图形学初学者)一文搞懂抽象的顶点、UV、索引。我们可能经常见过这些数据,也经常用,但我们很有可能搞了半年三维了,却不一定真的知道它们怎么写、它们的具体概念。毕竟成熟的软件导出模型,直接会帮我们做好这些底层工作,我们完全无需了解......

img

↑ 手搓工具示意图

什么是三维模型顶点数据?

这是在图形学里面,组成一个三维模型的基本元素,包括每个顶点的坐标、每个面的法线、纹理坐标(UV)、颜色、面的索引等等,这里以 webgl 为例子,要想建立一个小模型,最基本的元素是 ----- 顶点坐标。

比如说你想搞一个立方体,那么最简单的方式就是把 8 个顶点坐标给确定了,然后按照一定的顺序来写就能让 webgl 画出来,嘿嘿。

img

↑ 一个立方体的各个顶点的坐标

img

↑ 立方体每个面由三角组成的示意图

在三维模型里,面都是由三角形组成的,因为三角形能组合成任意的形状嘛 o( ̄▽ ̄)d ,无论是矩形、六边形、还是五角星.....
在写顶点时,按照三角形的顺序,三三一组来写,就能写出一个立方体。
这是最古老的方法了。可是!只要我们一实践,马上就能发现一个问题!
我们原以为,不就 8 个点吗?等我们写出来,我们会发现在浏览器上一个一眼能望穿的立方体,竟然得写 8 × 2 = 16 个三角形组(),8 × 2 × 3 = 48 行,长长的数据(如代码所示):

// 一个立方体的顶点数据
const vertices = new Float32Array([
    // === 1. 前面 (Front Face) ===
    // 第一个三角形
   -0.5, -0.5,  0.5,  // 左下角
    0.5, -0.5,  0.5,  // 右下角
    0.5,  0.5,  0.5,  // 右上角
    // 第二个三角形
   -0.5, -0.5,  0.5,  // 左下角 
    0.5,  0.5,  0.5,  // 右上角 
   -0.5,  0.5,  0.5,  // 左上角

    // === 2. 后面 (Back Face) ===
    // 第一个三角形
   -0.5, -0.5, -0.5,  // 左下角
    0.5,  0.5, -0.5,  // 右上角
    0.5, -0.5, -0.5,  // 右下角
    // 第二个三角形
   -0.5, -0.5, -0.5,  // 左下角
   -0.5,  0.5, -0.5,  // 左上角
    0.5,  0.5, -0.5,  // 右上角

    // === 3. 上面 (Top Face) ===
    // 第一个三角形
   -0.5,  0.5,  0.5,  // 前左上
    0.5,  0.5,  0.5,  // 前右上
    0.5,  0.5, -0.5,  // 后右上
    // 第二个三角形
   -0.5,  0.5,  0.5,  // 前左上
    0.5,  0.5, -0.5,  // 后右上
   -0.5,  0.5, -0.5,  // 后左上

    //=== 4. 下面 (Bottom Face) ===
    // 第一个三角形
   -0.5, -0.5,  0.5,  // 前左下
    0.5, -0.5, -0.5,  // 后右下
    0.5, -0.5,  0.5,  // 前右下
    // 第二个三角形
   -0.5, -0.5,  0.5,  // 前左下
   -0.5, -0.5, -0.5,  // 后左下
    0.5, -0.5, -0.5,  // 后右下

    // === 5. 右面 (Right Face) ===
    // 第一个三角形
    0.5, -0.5,  0.5,  // 前右下
    0.5, -0.5, -0.5,  // 后右下
    0.5,  0.5, -0.5,  // 后右上
    // 第二个三角形
    0.5, -0.5,  0.5,  // 前右下
    0.5,  0.5, -0.5,  // 后右上
    0.5,  0.5,  0.5,  // 前右上

    // === 6. 左面 (Left Face) ===
    // 第一个三角形
   -0.5, -0.5,  0.5,  // 前左下
   -0.5,  0.5,  0.5,  // 前左上
   -0.5,  0.5, -0.5,  // 后左上
    // 第二个三角形
   -0.5, -0.5,  0.5,  // 前左下
   -0.5,  0.5, -0.5,  // 后左上
   -0.5, -0.5, -0.5   // 后左下
  ]
]);

(注:这个数据,是以立方体中心为 0,0,0 的)
长度可能会惊掉我们的下巴,哈哈。现在,我们就来使用我们的工具来将其写到我们的页面上。以便后面的学习!

手搓工具的使用

在线地址在这里 ->: 手搓模型顶点数据工具

当然,如果想下载的话,直接 Ctrl + S 保存网页也行,但很遗憾,因为跨域请求的原因,只有在 http 环境下才能加载图片纹理,这个要注意。

img

↑ 我的这个工具的界面介绍图

不同于三维设计软件热衷于的第三视角旋转平移缩放,俺这个工具是第一视角的,类似于 GTA 游戏、fps 游戏。但有稍稍不一样:

而左侧是编辑框,也使用了大名鼎鼎的 areaEditor.js ,一个只有 2kb 的超轻量级代码编辑器,能自动换行、括号补全、tab 补全......
打开后,编辑框里面默认显示了我们本节的最终结果,一个简简单单,矮矮的六棱柱!现在,我们先将上一节的立方体数据输入进来,体会一下它的使用!

使用数据生成一个立方体

第一步,删去里面的杂项,只留下下面的内容(也就是我们现在只需要顶点):

modDataDIY = {
    // 顶点
    vertices:[],
}

第二步,我们使用伟大的复制 粘贴,将关键的代码,也就是我们的立方体顶点数据粘贴进去,最终如下:

modDataDIY = {
    // 顶点
    vertices:[
        -0.5, -0.5,  0.5,  
        0.5, -0.5,  0.5,  
        0.5,  0.5,  0.5,  
        -0.5, -0.5,  0.5,   
        0.5,  0.5,  0.5,   
        -0.5,  0.5,  0.5,  

        -0.5, -0.5, -0.5,  
        0.5,  0.5, -0.5,  
        0.5, -0.5, -0.5,  
        -0.5, -0.5, -0.5,  
        -0.5,  0.5, -0.5,  
        0.5,  0.5, -0.5,  

        -0.5,  0.5,  0.5,  
        0.5,  0.5,  0.5,  
        0.5,  0.5, -0.5,  
        -0.5,  0.5,  0.5,  
        0.5,  0.5, -0.5,  
        -0.5,  0.5, -0.5,  

        -0.5, -0.5,  0.5,  
        0.5, -0.5, -0.5,  
        0.5, -0.5,  0.5,  
        -0.5, -0.5,  0.5,  
        -0.5, -0.5, -0.5,  
        0.5, -0.5, -0.5,  

        0.5, -0.5,  0.5,  
        0.5, -0.5, -0.5,  
        0.5,  0.5, -0.5,  
        0.5, -0.5,  0.5,  
        0.5,  0.5, -0.5,  
        0.5,  0.5,  0.5,  

        -0.5, -0.5,  0.5,  
        -0.5,  0.5,  0.5,  
        -0.5,  0.5, -0.5,  
        -0.5, -0.5,  0.5,  
        -0.5,  0.5, -0.5,  
        -0.5, -0.5, -0.5   
  ],
}

然后,单击 更新模型 按钮,然后我们画面里就出现了一个立方体!!!

img

↑ 生成的立方体

我们可以四处转转,观摩一下我们的这个立方体。现在我们基本会用这个工具了,接下来正式进入教学。

画三角

现在,我们刷新页面,单击更新模型,模型会变成默认的三角形。
好,我们先从最简单的一个三角形的面开始:

modDataDIY = {
    // 顶点
    vertices:[
        1,1,0,  // 0
        0,1,0,  // 1
        0,0,0,  // 2
    ],

    // UV
    uv:[
        0,1,  // 0
        1,1,  // 1
        1,0,  // 2
    ],

    // 索引
    indices:[
        1,2,0  // 面 1
    ],
}

效果是这样:

img

↑ 最最最简单的 三角形!

现在,我们就来讲一下,这三块数据分别代表什么!

img

↑ 顶点 和 索引

首先是顶点和索引。
顶点,在上一个章节里,我们有了解。在这个三角形中,顶点如上图所示。

每三个为一组,三行顶点,分别对应 0 点、1 点、2 点。系统会自动给我们分好类,这个就是点的编号!

这个编号,我们可以用在索引里!

那么这个三角形的索引,1, 2, 0 就可以表示我们的三角形了!我们就不用重复写点了!

问题来了, 索引表示点的顺序是怎么样的? 1,2,0 1,0,2 等等有区别吗?当然有区别了!

在 Webgl 里,每个面都有一个法线方向,用于确定哪边是面的正面,哪边是面的背面!这个时候就用到右手定则了。四个卷手指指向我们的索引顺序方向,此时大拇指指向的就是我们的面的法向法向了!

1, 2, 0 是逆时针,法向方向是 Z 轴正方向。

1, 0, 2 是顺时针,法线方向是 Z 轴负方向。

可是法向方向,也就是面的正面,定义这个有什么用?

它的用途很多,但最有标志性的就是 面剔除 !

面剔除

在我们的工具左下角,有一个 「隐藏不可见面」的勾选框,我们现在操作画面,绕到三角形前后面看一下,之后勾选后再看一下。

img

↑ 背面看不到三角形了

我们会发现勾选后,这个三角形只有正面会显示,而背面不显示。
这就是法线的最大意义,它规定了正面后,我们可以将另一个面剔除。
这样做有什么意义呢?
首先,什么叫「不可见面」!在这个赤裸裸的三角形里我们当然感觉没有用,但是在一些封闭的三维体呢?比如刚才的立方体,在正常情况下,我们肯定是不会看到立方体里面那个面的,因此,我们如果将每个面的法向都朝向外面,然后使用这个「面剔除」工具,这样我们的电脑就不会再去渲染立方体内部了,渲染压力顷刻间减少一半,意义非凡 (〃'▽'〃) !!!

传说中的 UV 是什么?

现在我们明白顶点、索引、法向了吧!!
接下来就是 UV 了。
UV 数据,三行分别对应三个点。我们为三个点,分别分配一个图的坐标,之后引擎会自动对这个图进行拉伸翻转,以适应我们的坐标。

img


↑ UV 坐标的解析

在我们的引擎里,每张图的左下为 (0, 0),右上为 (1, 1),如上图的左边的图为例(不同的渲染引擎,可能略有不同,以那些引擎的文档说明为最终依据)。
而如果我们将左下角的点定义成 (1, 0) 而不是 (0 ,0)、且左上角定义成 (1, 0) 或 右上定义成 (0, 1),那就会左右翻转....... 当然,你也可以想象一下怎么样就会上下翻转...
在我们的例子里,我们为这三个点分别分配那三个坐标,最终图片可不就是左右翻转...... (0, 0)到最右下了。
现在我们的纹理是左右翻转的,如果我们将 uv 改为如下,则图片将正常显示(大家仔细琢磨一下,该怎么改呢??没错,就是 2 点对应 (0, 0)):

    // UV
    uv:[
        1,1,  // 0
        0,1,  // 1
        0,0,  // 2
    ],

那么结果就是正常显示了!

img


↑ 图片正常显示

如果我们以下面这种写法来写,那就上下颠倒了!

img

↑ 上下颠倒就是 0(1,0) 1(0,0) 2(0,1)

那么代码就是:

    // UV
    uv:[
        1,0,  // 0
        0,0,  // 1
        0,1,  // 2
    ],

单击更新模型,效果是这样:

img

↑ 结果显然,上下颠倒了

那么如果不写 1 和 0 ,我们写成 (0.5, 0) 或 (5, 5) 这种呢?

img

↑ 两种奇葩写法的示意图

我们可以自己修改代码,它们最终的显示效果会分别如下所示:

img

↑ (5, 5) 写法

img

↑ (0.5, 0.5) 写法

当然,如果你使用不老实的写法,比如 (3.14 , 0) .....,那么它也会老老实实的扭曲一下。大家感兴趣可以试试......

搞一个正方形

基础知识我们掌握完毕,现在开始出活儿!

我们绘制了一个三角形了,我们现在需要将其补全,绘制为一个平面!

img

↑ 第 4 个点,即点三的示意图

我们现在添加第四个点,即点 3 (1, 0, 0)。

显然,它的 UV 应该是 (1,0)

显然,这是第二个面,所以索引要新增加一个 0, 2, 3 。

我们把代码整理一下,如下所示:

modDataDIY = {
    // 顶点
    vertices:[
        1,1,0,  // 0
        0,1,0,  // 1
        0,0,0,  // 2
        1,0,0,  // 3 <-
    ],

    // UV
    uv:[
        1,1,  // 0
        0,1,  // 1
        0,0,  // 2
        1,0,  // 3 <-
    ],

    // 索引
    indices:[
        1,2,0, // 面 1
        0,2,3, // 面 2 <-
    ],
}

最终的结果,也很如人意!

img

↑ 一个正方形

之后,就是我们的「六棱柱」策划了!

策划六棱柱

首先说明一下,我们以学习为主,所以搞的不是正六棱柱,那个还得计算 π,太麻烦。我们就以最简单的 0 1 2 来定义坐标。

因为点并不多,所以我们在纸上将所有点先都画出来,算好我们的坐标,12 个点每个点的坐标。

我们三棱柱每个点的坐标

img

↑ 我们先做第二个面吧,即 P4 P5 P6 P7 那个面。

按照我们做的第一个正方形的样子,做第二个正方形,代码如下:

modDataDIY = {
    // 顶点
    vertices:[
        1,1,0,  // 0
        0,1,0,  // 1
        0,0,0,  // 2
        1,0,0,  // 3

        1,1,2,  // 4
        0,1,2,  // 5
        0,0,2,  // 6
        1,0,2,  // 7
    ],

    // UV
    uv:[
        1,1,  // 0
        0,1,  // 1
        0,0,  // 2
        1,0,  // 3

        1,1,  // 4
        0,1,  // 5
        0,0,  // 6
        1,0,  // 7
    ],

    // 索引
    indices:[
        // 后面
        1,2,0, // 三角形 1
        0,2,3, // 三角形 2

        // 前面
        4,5,6,  // 三角形 1
        4,6,7,  // 三角形 2
    ],
}

效果如下,还可以:

img

↑ 第二个面搞好了

然后做右前那个面。(P8 P4 P7 P9 那个面)

此时。我们会遇到一个问题.... UV 怎么写???

点重复了,我们的「前面」的 P4 使用 UV 一次,我们的「右前」又要使用一次 UV.....

是的,很遗憾, UV 并不会看索引,UV 只会根据点,所以我们还要再写一次顶点。唉.... 即便使用了索引,还是顶点重复。

现在,我们的代码如下:

modDataDIY = {
    // 顶点
    vertices:[
        1,1,0,  // 0
        0,1,0,  // 1
        0,0,0,  // 2
        1,0,0,  // 3

        1,1,2,  // 4
        0,1,2,  // 5
        0,0,2,  // 6
        1,0,2,  // 7

        2,1,1,  // 8
        1,1,2,  // 9 (4)
        1,0,2,  // 10 (7)
        2,0,1,  // 11 (9)
    ],

    // UV
    uv:[
        1,1,  // 0
        0,1,  // 1
        0,0,  // 2
        1,0,  // 3

        1,1,  // 4
        0,1,  // 5
        0,0,  // 6
        1,0,  // 7

        1,1,  // 8
        0,1,  // 9
        0,0,  // 10
        1,0,  // 11

    ],

    // 索引
    indices:[
        // 后面
        1,2,0, // 三角形 1
        0,2,3, // 三角形 2

        // 前面
        4,5,6,
        4,6,7,

        // 右前
        8,9,10,
        8,10,11,
    ],
}

效果还可以:

img

↑ 「右前」面搞好了

但我们不想这样重复眼花缭乱的定义坐标,那么我们就来搞个 js 吧。
我们事先,将这 12 个点定义好。如下所示:

var p0 = [1,1,0];
var p1 = [0,1,0];
var p2 = [0,0,0];
var p3 = [1,0,0];
var p4 = [1,1,2];
var p5 = [0,1,2];
var p6 = [0,0,2];
var p7 = [1,0,2];
var p8 = [2,1,1];
var p9 = [2,0,1];
var p10 = [-1,1,1];
var p11 = [-1,0,1];

接着使用 Array.fill() 结合展开语法 ... 将其嵌入到我们的顶点数组里,也就是类似下面这种语法:

var test = [1,2,3,4];
var myArr = [0,0,0,...test,0,0,0]
// 等同于 myArr = [0, 0, 0, 1, 2, 3, 4, 0, 0, 0]

那么就会大大的提高我们的手搓效率!
当然,UV 的模式也是一直在重复使用的,也可以抽离出来,于是我们可以这样写!一口气就能把我们的 6 个面都搞完!

var p0 = [1,1,0];
var p1 = [0,1,0];
var p2 = [0,0,0];
var p3 = [1,0,0];
var p4 = [1,1,2];
var p5 = [0,1,2];
var p6 = [0,0,2];
var p7 = [1,0,2];
var p8 = [2,1,1];
var p9 = [2,0,1];
var p10 = [-1,1,1];
var p11 = [-1,0,1];
var uvDefault = [1,1,0,1,0,0,1,0]; // 默认uv

modDataDIY = {
    // 顶点
    vertices:[
        ...p0,...p1,...p2,...p3, //后
        ...p4,...p5,...p6,...p7, //前
        ...p8,...p4,...p7,...p9, //右前
        ...p0,...p8,...p9,...p3, //右后
        ...p5,...p10,...p11,...p6,//左前
        ...p10,...p1,...p2,...p11,//左后
    ],

    // UV
    uv:[
        ...uvDefault,...uvDefault, //后前
        ...uvDefault,...uvDefault, //右前后
        ...uvDefault,...uvDefault, //左前后
    ],

    // 索引
    indices:[
        //1,2,0,0,2,3,// 后面(错误索引,删去)
        1,0,3,1,3,2,// 后面
        4,5,6,4,6,7,// 前面
        8,9,10,8,10,11,// 右前
        12,13,14,12,14,15,// 右后
        16,17,18,16,18,19,// 左前
        20,21,22,20,22,23,// 左后
    ],
}

看一下效果:

6 个面整整齐齐,看起来还不错!

img

貌似还不错,但是如果我们开始面剔除,也就是打钩 隐藏不可见面 的话..... 我们只希望朝外的面会被显示,很显然,我们的「后面」这个面,索引写错了。

1,0,3,1,3,2,// 「后面」索引修复!

这样,就正常了!

上下两个面之顶面

我们先搞「上面」。这是上面的点位图。我们这样规划三角形。当然,如果需要严谨的态度的话,这样显然是不行的,但我们只是学习,图省事!

「上面」的点位图:

img

按照法线朝外的顺序,很轻松就能写出来顶点和索引。

但是 UV 呢?这个时候,为了尽可能显示全图,图片可能会扭曲,但其实也不会太干扰观感。下面是每个点的 UV 值:

img

↑ 每个点的 UV 值

之后,我们很轻松就能将其写出来代码了!

var p0 = [1,1,0];
var p1 = [0,1,0];
var p2 = [0,0,0];
var p3 = [1,0,0];
var p4 = [1,1,2];
var p5 = [0,1,2];
var p6 = [0,0,2];
var p7 = [1,0,2];
var p8 = [2,1,1];
var p9 = [2,0,1];
var p10 = [-1,1,1];
var p11 = [-1,0,1];
var uvDefault = [1,1,0,1,0,0,1,0]; // 默认uv

modDataDIY = {
    // 顶点
    vertices:[
        ...p0,...p1,...p2,...p3, //后
        ...p4,...p5,...p6,...p7, //前
        ...p8,...p4,...p7,...p9, //右前
        ...p0,...p8,...p9,...p3, //右后
        ...p5,...p10,...p11,...p6,//左前
        ...p10,...p1,...p2,...p11,//左后

        //顶面 24~29
        ...p0,...p1,...p10,...p5,...p4,...p8,

    ],

    // UV
    uv:[
        ...uvDefault,...uvDefault, //后前
        ...uvDefault,...uvDefault, //右前后
        ...uvDefault,...uvDefault, //左前后
        // 顶面
        0.66,1,    0.33,1,
        0,0.5,    0.33,0,
        0.66,0,    1,0.5,
    ],

    // 索引
    indices:[
        //1,2,0,0,2,3,// 后面(错误索引,删去)
        1,0,3,1,3,2,// 后面
        4,5,6,4,6,7,// 前面
        8,9,10,8,10,11,// 右前
        12,13,14,12,14,15,// 右后
        16,17,18,16,18,19,// 左前
        20,21,22,20,22,23,// 左后
        24,25,26,   24,26,27,
        24,27,28,   24,28,29,
    ],
}

img

↑ 效果还可以

上下两个面之底面

然后就是最后一步了!

下面「底面」的坐标图:

img

↑ 「底面」的法向顺序

当然,要注意,我们是从下向上看,因此它的 UV 示意图是这样:

img

↑ 底面的 UV 示意图

现在我们应该很熟悉敲这个多边体的代码了。最终代码如下:

var p0 = [1,1,0];
var p1 = [0,1,0];
var p2 = [0,0,0];
var p3 = [1,0,0];
var p4 = [1,1,2];
var p5 = [0,1,2];
var p6 = [0,0,2];
var p7 = [1,0,2];
var p8 = [2,1,1];
var p9 = [2,0,1];
var p10 = [-1,1,1];
var p11 = [-1,0,1];
var uvDefault = [1,1,0,1,0,0,1,0]; // 默认uv

modDataDIY = {
    // 顶点
    vertices:[
        ...p0,...p1,...p2,...p3, //后
        ...p4,...p5,...p6,...p7, //前
        ...p8,...p4,...p7,...p9, //右前
        ...p0,...p8,...p9,...p3, //右后
        ...p5,...p10,...p11,...p6,//左前
        ...p10,...p1,...p2,...p11,//左后

        //顶面 24~29
        ...p0,...p1,...p10,...p5,...p4,...p8,
        //底面 30~35
        ...p2,...p3,...p9,...p7,...p6,...p11,
    ],

    // UV
    uv:[
        ...uvDefault,...uvDefault, //后前
        ...uvDefault,...uvDefault, //右前后
        ...uvDefault,...uvDefault, //左前后
        // 顶面
        0.66,1,    0.33,1,
        0,0.5,    0.33,0,
        0.66,0,    1,0.5,

        // 底面
        0.66,1,    0.33,1,
        0,0.5,    0.33,0,
        0.66,0,    1,0.5,
    ],

    // 索引
    indices:[
        //1,2,0,0,2,3,// 后面(错误索引,删去)
        1,0,3,1,3,2,// 后面
        4,5,6,4,6,7,// 前面
        8,9,10,8,10,11,// 右前
        12,13,14,12,14,15,// 右后
        16,17,18,16,18,19,// 左前
        20,21,22,20,22,23,// 左后
        // 顶面
        24,25,26,   24,26,27,
        24,27,28,   24,28,29,

        // 底面
        30,31,32,    30,32,33,
        30,33,34,    30,34,35,
    ],
}

最终,显示效果如下:

img

↑ 一个完整的六棱柱制作好了

到现在,我们成功的将六棱柱给搞定了!现在我们单击隐藏面,看看是否有缺陷,如果没有,那么我们的手搓六棱柱模型数据就完成了。我们对顶点、UV、索引、法线的认识将会更加明澈!

标签: 原创 三维 webgl 教程

评论:
avatar
Lvtu 3 个月前
看不懂,真的看不懂,膜拜~~
commentator
独元殇 3 个月前
@Lvtu:其实就是点都在哪里,怎么铺纹理,哪一面是正面。
avatar
Lucky 4 个月前
不准备回深圳搬砖了吗?
commentator
独元殇 3 个月前
@Lucky:暂时先不去了
avatar
2broear 4 个月前
这个demo不错啊,可以做恐怖游戏了
commentator
独元殇 3 个月前
@2broear:https://js13kgames.com/2024/games/13th-floor    这是一个只有 12.99 kb 的恐怖 3D 游戏,很厉害