大家好,我是西瓜,你现在看到的是 2021 iThome 铁人赛『如何在网页中绘制 3D 场景?从 WebGL 的基础开始说起』系列文章的第 26 篇文章。本系列文章从 WebGL 基本运作机制以及使用的原理开始介绍,最後建构出绘制 3D、光影效果之网页。如果在阅读本文时觉得有什麽未知的东西被当成已知的,可能可以在前面的文章中找到相关的内容
到了本系列文章的尾声,本章节将制作一个完整的场景作为完结作品:主角是一艘帆船,在一片看不到边的海面上,天气晴朗。
06-boat-ocean.html
/ 06-boat-ocean.js
基於先前制作的光影效果,笔者制作了新的起始点:
此起始点相较於 Day 25,主要有以下修改:
twgl.createTextures()
使 texture 之读取、建立程序可以大幅缩短twgl.resizeCanvasToDisplaySize()
取代 canvas 之大小调整,同时在右上角让使用者调整解析度倍率,一般来说是 普通
也就是一倍,如果 window.devicePixelRatio
大於 1(例如 Retina 萤幕),使用者也可以提高使用完整的萤幕解析度可以看到画面上有一颗从上上个章节就一直存在的球体,第一个目标就是将之换成新主角 -- 帆船
.obj
/ .mtl
档案.obj
存放的是 3D 物件资料,精确的说,经过读取後可以成为 vertex attribute 的资料来源,包含了各个顶点的位置、texcoord、法向量,成为 3D 场景中的一个物件,而 .mtl
则是存放材质资料,像是散射光、反射光的颜色等
笔者先前在练习 .obj
的读取时,顺便小小学习了 Blender 这套开源的 3D 建模软件,并且制作了一艘船:
https://sketchfab.com/3d-models/my-first-boat-f505dd73384245e08765ea6824b12644
这个模型就成了笔者接下来练习时需要模型时使用的素材,同时也是我们要放入场景中的帆船,汇出成 .obj
& .mtl
之後,用文字编辑器打开其 .obj
可以看到:
# Blender v2.93.0 OBJ File: 'my-first-boat.blend'
# www.blender.org
mtllib my-first-boat.mtl
o Cube_Cube.001
v -0.245498 -0.021790 2.757482
v -0.551836 0.552017 2.746644
v -0.371110 -0.118091 0.326329
...
vt 0.559949 0.000000
vt 0.625000 0.000000
vt 0.625000 0.250000
...
vn -0.7759 -0.6250 0.0861
vn 0.0072 -0.0494 -0.9988
vn 0.7941 -0.6020 0.0836
...
usemtl body
...
f 17/1/1 2/2/1 4/3/1 18/4/1
f 18/4/2 4/3/2 8/5/2 19/6/2
f 19/6/3 8/5/3 6/7/3 20/8/3
...
o Cylinder.004_Cylinder.009
v 0.000000 0.308823 0.895517
v 0.000000 0.640209 0.895517
...
.obj
要纪录 3D 物件的每个顶点资料,想当然尔档案通常不小,这个模型有 20.6k 个三角形,档案大小约 1.3MB,这边不会看全部的细节,只撷取了一些小片段来观察其内容
mtllib my-first-boat.mtl
的 mtllib
开头表示使用了 my-first-boat.mtl
这个档案来描述材质o Cube_Cube.001
的 o
开头表示一个子物件的开始,Cube_Cube.001
这个名字来自於 blender 中的物件名称v -0.245498 -0.021790 2.757482
/ vt 0.559949 0.000000
/ vn -0.7759 -0.6250 0.0861
的 v
/ vt
/ vn
开头分别为位置、texcoord、法向量资料,实际去打开档案可以看到 .obj
绝大部分的内容都像这样usemtl body
表示这个子物件要使用的材质的名字,理论上可以在 .mtl
中找到对应的名字f 17/1/1 2/2/1 4/3/1 18/4/1
表示一个『面』,这边是一个四边形,有四个顶点,每个顶点分别用一个 index 数字表示使用的哪一笔位置、texcoord、法向量,类似於 Day 18 之 indexed elemento
开头 o Cylinder.004_Cylinder.009
表示另一个子物件的开始,Cube_Cube.001
这个名字来自於 blender 中的物件名称同样地,看一下 .mtl
的片段:
# Blender MTL File: 'my-first-boat.blend'
# Material Count: 10
newmtl Material.001
Ns 225.000000
Ka 1.000000 1.000000 1.000000
Kd 0.352941 0.196078 0.047058
Ks 0.500000 0.500000 0.500000
Ke 0.000000 0.000000 0.000000
Ni 1.450000
d 1.000000
illum 2
...
newmtl flag-my-logo
Ns 225.000000
Ka 1.000000 1.000000 1.000000
Kd 0.000000 0.000000 0.000000
Ks 0.500000 0.500000 0.500000
Ke 0.000000 0.000000 0.000000
Ni 1.450000
d 1.000000
illum 2
map_Kd me.png
这个档案就相对小很多,newmtl Material.001
对应 .obj
中的材质名称,接下来则是对於不同光线的颜色或参数,根据这边的定义,Ka
表示环境光颜色、Kd
散射光颜色、Ks
反射光颜色、Ns
反射光『范围参数』(u_specularExponent
),Ke
在 stackoverflow 上说是自发光的颜色;最後帆船模型中间船桅上有一面旗子,旗子中的图案使用了 texture,因此 flag-my-logo
这个材质有一个参数 map_Kd me.png
表示要使用 me.png
这个图档作为 texture;剩下的设定在我们的 shader 中也没有相关的实做,就先忽略不管
开始写程序读取 .obj
之前,可以从这边下载 my-first-boat.obj
, my-first-boat.mtl
以及 me.png
放置在专案 assets/
资料夹下
这边的
.mtl
档案与上传到 sketchfab.com 的有点不同,因为本系列文实做的 shader 会导致一些材质颜色不明显,因此笔者有手动调整.mtl
部份材质的颜色
.obj
& .mtl
好的,综观来看,自己写读取程序的话,除了 .obj
/ .mtl
parser 之外,得从 f
开头的『面』资料展开成一个个三角形,接着取得要使用的位置、texcoord、法向量,转换成 buffer 作为 vertex attribute 使用,除此之外还要处理 .mtl
的对应、建立子物件等,显然是个不小的工程;既然 .obj
是一种公用格式,那麽应该可以找到现成的读取工具,笔者找到的是这款:
https://github.com/frenchtoast747/webgl-obj-loader
可惜作者没有提供 ES module 的方式引入,因此笔者 fork 此专案并且修改使之可以产出 ES module 的版本:github.com/pastleo/webgl-obj-loader,下载 build 好的 webgl-obj-loader.esm.js
并放至於专案的 vendor/webgl-obj-loader.esm.js
,接着在 06-boat-ocean.js
就可以直接引入:
import * as WebGLObjLoader from './vendor/webgl-obj-loader.esm.js';
接着建立一个 function 来串接 WebGLObjLoader
读取 .obj
, .mtl
并传入 WebGL,经过一些 survey 之後笔者使用它的 WebGLObjLoader.downloadModels()
,可以同时下载所有需要的档案并解析好,包含 .mtl
甚至 texture 图档,先看一下经过 WebGLObjLoader
读取好的资料看起来如何:
async function loadBoatModel(gl) {
const { boatModel } = await WebGLObjLoader.downloadModels([{
name: 'boatModel',
obj: './assets/my-first-boat.obj',
mtl: true,
}]);
console.log(boatModel);
}
在 setup()
中呼叫:
async function setup() {
// ...
await loadBoatModel(gl);
// ...
}
配合其文件的说明,.vertices
对应 a_position
、.vertexNormals
对应 a_texcoord
、.textures
对应 a_normal
,但是这些 vertex attribute 不能直接使用,而是要透过 .indices
指向每个顶点对应的资料,同时 .indices
已经是 Day 18 的 indexed element 所需要之 ELEMENT_ARRAY_BUFFER
,不像是 .obj
中一个个 f
开头的顶点指向不同组 position/texcoord/normal
那材质的部分呢?在我们的实作中同一个物件一次渲染只能指定一组 u_diffuse
, u_specular
等 uniform 让物件为一个单色,要不然就是用 u_diffuseMap
指定 texture,直接使用 .indices
作为 ELEMENT_ARRAY_BUFFER
的话便无法使不同子物件使用不同的材质,幸好 WebGLObjLoader
所回传的物件中有 .indicesPerMaterial
,里面包含了一个个的 indices 阵列,分别对应一组材质设定,有趣的事情是,这些 indices 所对应的实际 vertex attribute 是共用的,也就是说 position/texcoord/normal 的 buffer 只要建立一组,接下来每个子物件建立各自的 indices buffer 并与共用 position/texcoord/normal 的 buffer 组成『物件』 VAO,最後渲染时各个物件设定好各自的 uniform 後进行绘制即可
因此在 WebGLObjLoader.downloadModels()
之後建立共用的 bufferInfo:
async function loadBoatModel(gl) {
const { boatModel } = await WebGLObjLoader.downloadModels([{ /* ... */ }]);
const sharedBufferInfo = twgl.createBufferInfoFromArrays(gl, {
position: { numComponents: 3, data: boatModel.vertices },
texcoord: { numComponents: 2, data: boatModel.textures },
normal: { numComponents: 3, data: boatModel.vertexNormals },
});
}
接下来让 app.objects.boat
表示整艘帆船,但是要一个一个绘制子物件,因此使 app.objects.boat
为一个阵列,每一个元素包含子物件的 bufferInfo, VAO 以及 uniforms,从 boatModel.indicesPerMaterial.map()
出发:
async function loadBoatModel(gl, programInfo) {
// ...
return boatModel.indicesPerMaterial.map((indices, mtlIdx) => {
const material = boatModel.materialsByIndex[mtlIdx];
const bufferInfo = twgl.createBufferInfoFromArrays(gl, {
indices,
}, sharedBufferInfo);
return {
bufferInfo,
vao: twgl.createVAOFromBufferInfo(gl, programInfo, bufferInfo),
}
});
}
.indicesPerMaterial
阵列中,第几个 indices 阵列就使用第几个 material,因此 boatModel.materialsByIndex[mtlIdx];
取得对应的材质设定twgl.createBufferInfoFromArrays()
的第三个参数 srcBufferInfo
来『共用』刚才建立的 sharedBufferInfo
,这感觉其实有点像是 Map 的 merge 或是 Object.assign()
loadBoatModel()
传入 programInfo
,以便建立 VAO这样一来 vertex attribute buffer, indices buffer 以及 VAO 就准备好了,剩下的就是把材质资料转成 uniforms key-value 物件,把这边取得的 material
印出来看:
虽然这个物件有不少东西,不过要找到 u_diffuse
, u_specular
对应的资料不会很困难,名字几乎能够直接对起来;如果是有 texture 的,可以在 material.mapDiffuse.texture
找到,而且已经是 Image 物件,直接喂给 twgl.createTexture()
即可:
async function loadBoatModel(gl, textures, programInfo) {
// ...
return boatModel.indicesPerMaterial.map((indices, mtlIdx) => {
const material = boatModel.materialsByIndex[mtlIdx];
let u_diffuseMap = textures.nil;
if (material.mapDiffuse.texture) {
u_diffuseMap = twgl.createTexture(gl, {
wrapS: gl.CLAMP_TO_EDGE, wrapT: gl.CLAMP_TO_EDGE,
min: gl.LINEAR_MIPMAP_LINEAR,
src: material.mapDiffuse.texture,
});
}
return {
/* bufferInfo, vao */,
uniforms: {
u_diffuse: material.diffuse,
u_diffuseMap,
u_specular: material.specular,
u_specularExponent: material.specularExponent,
u_emissive: material.emissive,
u_ambient: [0.6, 0.6, 0.6],
},
}
});
}
对於没有使用 texture 的子物件,就跟之前一样要设定成 texture.nil
避免影响到单色渲染,令一个比较特别的是 u_ambient
,因为笔者为此系列文撰写的 shader 运作方式与 blender、sketchfab.com 上看到的不同,或许是有些材质的设定没实做的关系,会显得特别暗,同时 u_ambient
这边实做的功能是基於 diffuse 的最低亮度,因此笔者一律设定成 [0.6, 0.6, 0.6]
因为原本 u_ambient
为全域的 uniform,而之後会变成各个物件个别设定,最後在 setup()
中传入所需的参数并接收子物件阵列到 app.objects.boat
准备好:
async function setup() {
// ...
+ objects.boat = await loadBoatModel(gl, textures, programInfo);
// ...
}
function render(app) {
// ...
const globalUniforms = {
u_worldViewerPosition: cameraMatrix.slice(12, 15),
u_lightDirection: lightDirection,
- u_ambient: [0.4, 0.4, 0.4],
// ...
}
}
function renderBall(app, viewMatrix, programInfo) {
// ...
twgl.setUniforms(programInfo, {
// ...
u_emissive: [0.15, 0.15, 0.15],
+ u_ambient: [0.4, 0.4, 0.4],
// ...
});
}
function renderOcean(app, viewMatrix, reflectionMatrix, programInfo) {
// ...
twgl.setUniforms(programInfo, {
// ...
u_emissive: [0, 0, 0],
+ u_ambient: [0.4, 0.4, 0.4],
// ...
});
// ...
}
这样一来 app.objects.boat
就准备好帆船的资料了,虽然画面上没有变化,但是可以在 Console 上输入 app.objects.boat
来确认:
确认资料准备好了,待下篇来把球体换成帆船,绘制 .obj
模型到画面上!本篇的完整程序码可以在这边找到:
>>: 入门魔法 - 常用阵列方法(一) forEach、filter、 map
用於双向的单次讯息传递,包括发送讯息、接收讯息两个功能 经过以上的实作,其实两端的通讯步骤都差不多,...
在串接API时,遇到最大的坎就是Message内文加密了, 就让我们来试看看罗~ Message内文...
今天的内容为该如何制作血量,并且在攻击时或受到伤害时改变血量。 ...
今天要学习的是 React 的新功能 Hook,而 Hook 是 React 在 16.8 版本中新...
今天介绍一下Yolo v3, 首先在v3中使用了darknet-53的架构,架构如下图: 相比v2的...