three.js 纹理贴图原理与实践

原创
2023/02/03 12:47
阅读数 1.5K

纹理贴图是 20世纪90 年代 CG 的主要创新之一。 它允许我们在不添加大量几何基元(线、顶点、面)的情况下添加大量表面细节。 想一想 Caroline 的 loadedDemo 的所有纹理映射是多么有趣: 在这里插入图片描述

推荐:使用 NSDT场景编辑器 快速搭建 3D场景。

1、概念图

纹理贴图将图片绘制到多边形上。 虽然名称是纹理贴图,但一般方法只是采用像素数组并将它们绘制到表面上。 像素数组只是一张图片,它可能是布料、砖块或草地之类的纹理,也可能是荷马·辛普森 (Homer Simpson) 的照片。 它可能是你的程序计算和使用的东西。 更有可能的是,它是你从原始图像文件加载的内容。

演示:这些都是 307 演示列表的一部分。 你还不必担心代码。 我们稍后再看。

平面旗帜:这些纹理是我们用 JavaScript 计算的简单数组。 它们被映射到一个平面上:

  • 灰度(黑白)棋盘,
  • RGB 棋盘(黑色和红色)
  • 灰度美国国旗
  • 红色、白色和蓝色美国国旗

平面上的巴菲:这些是从单独的图像文件加载的纹理。

从概念上讲,要使用纹理,你必须执行以下操作:

  • 定义一个纹理:一个矩形像素阵列——纹素,纹理元素的缩写。 我们几乎可以互换使用这些术语,其中纹素是用于纹理映射的数组中的像素。
  • 为几何体的每个顶点指定一对纹理坐标 (s,t)
  • 图形系统将纹理“绘制”到多边形上。

2、纹理贴图原理

纹理贴图是一种光栅操作,不同于我们已经看过的任何其他东西。 然而,我们在 3D 模型中将纹理应用于 2D 表面,并且图形系统必须弄清楚如何在光栅化(也称为扫描转换)期间修改像素。

由于纹理映射是光栅化过程的一部分,所以让我们从这里开始。

3、光栅化

当显卡渲染多边形时,它理论上:

  • 确定每个角的像素坐标。
  • 确定多边形的边缘像素,使用画线程序(一个重要的是 Bresenham 的算法,我们没有时间研究)。
  • 确定单行边缘像素的颜色(通过顶点颜色的线性插值)。
  • 沿着行着色每个像素(通过两个边缘像素的线性插值)。

注意:标准术语是多边形称为片元(Fragment),因为它可能是贝塞尔曲面的片段或某种类似的多边形近似值。 因此,显卡将纹理应用于片元。

这一切都发生在帧缓冲区(保存屏幕上显示的像素的视频内存)或类似的数组中。

4、纹理贴图的实现

要进行纹理贴图,显卡必须:

  • 在光栅化过程中使用双线性插值计算每个像素的纹理坐标
  • 在纹素数组中查找纹理坐标(使用最近的或四个最近的线性插值)
  • 使用纹理的颜色作为像素的颜色,或者组合纹理和像素的颜色

5、纹理空间

我们可以有 1D 或 2D 纹理,尽管几乎总是 2D。 纹理参数将在每个维度的 [0,1] 范围内。 请注意,如果你的纹理数组不是正方形并且你的多边形也不是正方形,则你可能需要处理纵横比的变化。

纹理始终是一个数组,因此始终是一个矩形。 将纹理映射到矩形(作为 OpenGL 对象)相当容易; 将其映射到其他形状可能会导致失真。 在这些情况下我们需要小心。

将多边形的每个顶点与纹理参数相关联,就像我们将它与法线、颜色等相关联一样。 Three.js 具有 Geometry 对象的属性,专门用于表示三角形面的每个顶点的纹理坐标。

在这里插入图片描述

纹理坐标与二维纹理元素数组有何关系? 这最容易用这样的图片来解释: 在这里插入图片描述

这是一个包含 260 个像素的阵列,编号从 0 到 259,排列成 13 x 20 像素的矩形阵列。 注意,这在 OpenGL 和 Three.js 中是非法的,因为这两个维度都不是 2 的幂,但我们还是使用它吧。

  • 如你所料,纹素数组的第一个元素,即元素 [0][0] 与纹理坐标 (0,0) 相同。
  • 当我们沿着数组的第一行向下移动,直到到达元素 [0][RowLength]([0][19],即元素 19),我们到达纹理坐标 (1,0)。 这可能看起来很奇怪,但这是事实。
  • 当我们沿着数组的第一列向下移动时,直到到达元素 [ColLength][0]([12][0],即元素 240),我们到达纹理坐标 (0,1)。 同样,这可能看起来很奇怪,但这是事实。
  • 不出所料,纹素数组的最后一个元素是第一个元素对面的角,因此数组元素 [ColLength][RowLength]([12][19] 即元素 259)对应于纹理坐标 (1,1)。

通常,纹理坐标称为 (s,t),就像空间坐标称为 (x,y,z) 一样。 因此,我们可以说 s 沿着纹理的行(沿着旗帜的“fly”)。 t 坐标沿着纹理的列(沿着旗帜的“hoist”)。

虽然你经常会使用整个纹理,所以你所有的纹理坐标都是 0 或 1,但这不是必需的。 事实上,由于纹理数组的维度需要是 2 的幂,所以你想要的实际图像通常只是整个数组的一部分。

计算出的美国国旗数组具有该属性。 该数组为 256 像素宽 x 128 像素高,但旗帜本身为 198 像素宽 x 104 像素高。 因此,最大纹理坐标(如果你只想要标志而不想要灰色区域)是:

fly	= 198/256 = 0.7734
hoist	= 104/128 = 0.8125

在这里插入图片描述

结果可能如上图所示。

当然,我们还需要确保我们放置国旗的矩形与美国国旗的纵横比相同,即:1.9。 请参阅官方美国国旗规格。

纹理参数也可以大于1,在这种情况下,可以使用参数设置来获得纹理的重复。 如果 s 是某个参数,其中 0 < s < 1,中途指定纹理的某个部分,则 1+s、2+s 等是纹理中的相同位置。

6、使用计算的纹理

让我们从使用计算纹理的纹理贴图开始。 因为它们是经过计算的,所以它们会非常简单,但我们使用它们有两个原因:

  • 它强化了纹理只是一个数组的概念,并且
  • 它避免了加载额外文件和必须使用事件处理程序的问题

现在是时候查看我们第一个基本演示的代码了。

这是平面旗帜演示。 创建棋盘等的代码中包含什么并不重要,但只要意识到每个都返回一个像素数组即可。 最重要的代码行在最后。 在这里插入图片描述

这是代码的基本部分。 请特别注意 makeFlag() 的实现。 我试图让它尽可能简单。 下面的代码仅用来:

  • 设置纹理
  • 创建网格

请注意,纹理是材质的属性,而不是几何体的属性。 但是,几何体为每个顶点定义(默认的)纹理参数。 面的各个像素的纹理参数是通过面部三个顶点的纹理参数进行插值来完成的。

其他一切都与我们之前看到的相似:

TW.makeFlagTexture = function (nickname) {
    // creates a texture as an array, then creates and returns an
    // THREE.DataTexture possible nicknames are 'nascar', 'checks'
    // 'US-Gray' and 'US-RWB' The last is the red, white and blue US flag.
    var size, array, width, height, format;
    switch (nickname) {
    case 'nascar':
        size = 3;
        array = TW.createCheckerboardGray(size);
        width = height = TW.power2(size);
        format = THREE.LuminanceFormat;
        break;
    case 'checks':
        size = 3;
        array = TW.createCheckerboardRedWhite(size);
        width = height = TW.power2(size);
        format = THREE.RGBFormat;
        break;
    case 'US-Gray':
        size = 4;
        array = TW.createUSFlagGray(size);
        height = TW.power2(size);
        width = 2*height;
        format = THREE.LuminanceFormat;
        break;
    case 'US-RWB':
        size = 4;
        array = TW.createUSFlagRedWhiteBlue(size);
        height = TW.power2(size);
        width = 2*height;
        format = THREE.RGBFormat;
        break;
    default:
        throw "don't know this flag nickname: "+nickname;
    }
    // console.log("flag stuff: ",array, width, height, format);
    var obj = new THREE.DataTexture( array, width, height, format);
    // we'll explain these filters soon
    obj.minFilter = THREE.NearestFilter;
    obj.magFilter = THREE.NearestFilter;
    obj.needsUpdate = true;
    return obj;
}
 
 
function makeFlag(name) {
    var flagTexture = TW.makeFlagTexture(name);
    var flagGeom = new THREE.PlaneGeometry( 8, 4);
    var flagMat = new THREE.MeshBasicMaterial(
        {
            color: THREE.ColorKeywords.white,
            map: flagTexture,
        });
    var flagMesh = new THREE.Mesh( flagGeom, flagMat );
    return flagMesh;
}

7、设置纹理坐标

前面我们看到几何对象定义每个顶点的纹理坐标。 更早的时候,我们并不总是想使用默认的 (0,1) 纹理坐标。 我们可能想使用 (0.77,0.81) 作为美国国旗的最大纹理坐标。 那么,如何更改默认纹理坐标,或将它们设置在你自己的几何对象上?

在 THREE.js 中,纹理坐标保存在 THREE.Geometry 的一个名为 faceVertexUvs 的属性中,有些人不使用 S 和 T,而是使用 U 和 V;它们都出现在 THREE.js 代码中。 这个属性是单元素数组(目前我还没有确定),该元素是一个面uv的数组,其中一个面UV是一个三元数组,对应面的三个顶点,每一个 其中的元素是一个 THREE.Vector2,它捕获 U 和 V 值。

让我们试着用一个具体的例子来理解这一点。 我们将考虑之前用于映射 Buffy 面部的几何对象。 这是一个简单的二维平面(一个矩形):

  planeGeom = new THREE.PlaneGeometry( 4, 4);

让我们看看这个数据结构。 首先,顶点:

JSON.stringify(planeGeom.vertices)
[{"x":-2,"y":2,"z":0},   // 0
 {"x":2,"y":2,"z":0},    // 1
 {"x":-2,"y":-2,"z":0},  // 2
 {"x":2,"y":-2,"z":0}    // 3
]

没有什么太令人惊讶的了。 有四个顶点,所有 z = 0,x 和 y 值在 +2 和 -2 中。 现在让我们看看这两个面,它们的顶点定义为上面数组的索引。

planeGeom.faces[0]
THREE.Face3 {a: 0, b: 2, c: 1, normal: THREE.Vector3, vertexNormals: Array[3]…}
planeGeom.faces[1]
THREE.Face3 {a: 2, b: 3, c: 1, normal: THREE.Vector3, vertexNormals: Array[3]…}

所以,这两个三角形面就是左上三角形和右下三角形。 最后,让我们看看 6 个顶点中每个顶点的 UV 值(两个面各三个):

> JSON.stringify(planeGeom.faceVertexUvs)
[
  // array of two elements
  [
   [{"x":0,"y":1},{"x":0,"y":0},{"x":1,"y":1}],  // elt 0 is for face 0
   [{"x":0,"y":0},{"x":1,"y":0},{"x":1,"y":1}]   // elt 1 is for face 1
  ]
]

奇怪的是,这两个坐标在这些对象中被命名为“x”和“y”,而不是你可能期望的“u”和“v”(甚至是“s”和“t”)。

这是一张可能有帮助的图片: 在这里插入图片描述

六组纹理坐标,两个三角形面(绿色面和红色面)各三个。

8、修改 faceVertexUvs

考虑以下函数,它像我们一样更新 THREE.PlaneGeometry 的 S 和 T 值:

function updateTextureParams(quad, sMin, sMax, tMin, tMax) {
    var elt = quad.faceVertexUvs[0]; // dunno why they have this 1-elt array
    var face0 = elt[0];
    face0[0] = new THREE.Vector2(sMin,tMax);
    face0[1] = new THREE.Vector2(sMin,tMin);
    face0[2] = new THREE.Vector2(sMax,tMax);
    var face1 = elt[1];
    face1[0] = new THREE.Vector2(sMin,tMin);
    face1[1] = new THREE.Vector2(sMax,tMin);
    face1[2] = new THREE.Vector2(sMax,tMax);
    quad.uvsNeedUpdate = true;
}

使用该函数,我们可以将美国国旗映射到没有灰色区域的平面上:

在这里插入图片描述

然而,这样做的代码并不直观,因为默认的 THREE.js 行为是翻转垂直纹理参数。 这称为 .flipY。 所以,不是设置T 参数:

  • 从左上角的0开始,
  • 到左下角的 0.8

我们实际上将其设置为:

  • 从左下角的 0.2 = 1-0.8,
  • 到左上角的 1

也就是说,对于翻转的 Y,左上角的坐标为 (0,1),左下角的坐标为 (0,0.2)。 要拉出那一块,我们必须按照以下方式设置纹理参数:

  updateTextureParams(flagGeom,0,0.75,1-0.81,1);

9、加载图像

这个演示展示了一个图像文件被加载并纹理映射到我们之前使用的同一平面上。

不过这个代码有一个非常棘手的部分。 当我们计算一个数组并将其用作纹理时,该数组已经可用于渲染。 对于外部图像,在数据从某个网络源到达之前会有一些延迟。 这种延迟可能会持续到几百毫秒,但与代码在 JavaScript 中的运行速度相比,即使是几毫秒也是一个巨大的时间量。

因此,如果我们所做的唯一渲染是在引用图像之后,代码将根本无法工作。 这是我描述的情况的伪代码:

    var buffyTexture = new THREE.ImageUtils.loadTexture( "../../images/buffy.gif",
                                                         new THREE.UVMapping());
    var buffyMat = new THREE.MeshBasicMaterial(
        {color: THREE.ColorKeywords.white,
         map: buffyTexture});
    
    var buffyMesh = new THREE.Mesh( planeGeom, buffyMat );
    scene.add(buffyMesh);
    TW.render();

在第一行(当对图像的请求被发送到服务器时)和最后一行(当渲染场景时)之间根本没有时间加载图像。 如果你尝试这样做,平面将是空白的。

解决方案是使用事件处理程序。 事件处理程序是你希望在某些事件发生后运行的代码的通用解决方案。 在这种情况下,事件是图像数据终于从服务器到达。 然后事件处理程序可以调用渲染器。

THREE.js 做这件事的方式也很标准:传入一个函数,当事件发生时调用它。 这是改进后的代码:

 var planeGeom = new THREE.PlaneGeometry( 4, 4);
    var imageLoaded = false;
    var buffyTexture = new THREE.ImageUtils.loadTexture( "../../images/buffy.gif",
                                                         new THREE.UVMapping(),
                                                         // onload event handler
                                                         function () {
                                                             console.log("image is loaded.");
                                                             imageLoaded = true;
                                                             TW.render();
                                                         });
    var buffyMat = new THREE.MeshBasicMaterial(
        {color: THREE.ColorKeywords.white,
         map: buffyTexture});
    
    var buffyMesh = new THREE.Mesh( planeGeom, buffyMat );
    return buffyMesh;
}

在上面的代码中,我们传入了一个匿名函数作为事件处理程序。 当图像完成加载并渲染场景时,它会被调用。


原文链接:纹理贴图原理与实践 — BimAnt

展开阅读全文
加载中
点击引领话题📣 发布并加入讨论🔥
打赏
0 评论
0 收藏
0
分享
返回顶部
顶部