这可能是一个晦涩的话题,但是我觉得很有趣所以就写出来了。 这些东西并不是建议你要会做的,我只是认为通过这个话题能够让你对制作 WebGL 三维模型有一些理解。
有人问我怎么在 WebGL 中制作一个保龄球瓶,聪明的回答是 “使用一个三维建模工具例如Blender, Maya, 3D Studio Max, Cinema 4D, 等等”。 使用它创建一个保龄球瓶,导出,读取点坐标(OBJ 格式相对简单些)。
但是,这让我想到,如果他们想做一个模型库该怎么办?
这有几种方法,一种方法是将圆柱体按照正弦函数放置在合适位置上, 但这样表面并不平滑。一个标准的圆柱需要一些间距相等的圆环, 但当曲线变得锐利的时候所需圆环的数量就会很多。
在模型库中你需要制作一个二维轮廓或者是一个符合边缘的曲线,然后将他们加工成三维图形。 这里加工的意思就是将生成的二维点按照某些轴旋转。这样就可以很轻松的做出一些圆的物体, 例如碗,棒球棒,瓶子,灯泡之类的物体。
那么该怎么做呢?首先我们要通过某种方式生成一个曲线,计算曲线上的点。 然后使用矩阵运算将这些点按照某个轴旋转, 构建出三角形网格。
计算机中常用的曲线就是贝塞尔曲线,你可能在一些编辑器例如 Adobe Illustrator 或 Inkscape 或 Affinity Designer 中编辑过贝塞尔曲线。
贝塞尔曲线或三次贝塞尔曲线由 4 个点组成,2 个端点,2 个“控制点”。
这就是四个点
从 0 到 1 之间选一个数(叫做 t
),其中 0 是起点,1 是终点。
然后在每个线段中计算出与 t
相关的点,P1 P2
, P2 P3
, P3 P4
。
换句话说如果 t = .25
那么就计算出 P1
到 P2
距离为 25% 的点,
从 P2
到 P3
距离为 25% 的点,从 P3
到 P4
距离为 25% 的点。
你可以拖动滑块调整 t
的值,也可以拖动 P1
, P2
, P3
, 和 P4
调整位置。
对这些结果点做同样的操作,计算 t
对应的 Q1 Q2
和 Q2 Q3
之间的点。
最后在 R1 R2
中计算出与 t
相关的点。
红点的位置就构成了一个曲线。
这就是三次贝塞尔曲线。
注意到在上述差值过程中通过 4 个点差出 3 个点,3 个点差出 2 个点,最后从 2 个点差出 1 个点, 这并不是常用的做法,有人将这些数学运算简化成了一个公式,像这样
invT = (1 - t) P = P1 * invT^3 + P2 * 3 * t * invT^2 + P3 * 3 * invT * t^2 + P4 * t^3
其中 P1
, P2
, P3
, P4
就像上例中的四个点,
P
就是那个 红点。
在二维美术应用例如 Adobe Illustrator 中, 当你制作一个较长的曲线时通常是由一些小的四点片段组成的。 默认情况下应用将控制点沿着起/终点方向锁死, 确保在公共点部分方向相反。
看这个例子。移动 P3
或 P5
会同时移动另一个。
注意这个曲线是两段,P1,P2,P3,P4
和 P4,P5,P6,P7
。
只有在 P3
,P5
与 P4
的连线方向相反时曲线在这一点才会连续。
大多数应用可以让你断开连接,并获得一个锐利的拐点。
取消选中复选框然后拖拽 P3
或 P5
就会清晰看到独立的曲线。
接下来我们需要获得生成曲线上的点,通过给上方的公式提供 t
就可以生成一个点。
function getPointOnBezierCurve(points, offset, t) {
const invT = (1 - t);
return v2.add(v2.mult(points[offset + 0], invT * invT * invT),
v2.mult(points[offset + 1], 3 * t * invT * invT),
v2.mult(points[offset + 2], 3 * invT * t * t),
v2.mult(points[offset + 3], t * t *t));
}
然后可以计算一系列点。
function getPointsOnBezierCurve(points, offset, numPoints) {
const points = [];
for (let i = 0; i < numPoints; ++i) {
const t = i / (numPoints - 1);
points.push(getPointOnBezierCurve(points, offset, t));
}
return points;
}
注意: v2.mult
和 v2.add
是我加入的二维点运算辅助方法。
在图示中你可以选择点的个数,如果曲线比较锐利就可以多差值一些点, 如果曲线比较平缓就可以少插值一些点。一个解决办法是检查曲线的锐利程度, 如果过于锐利就拆分成两个曲线。
拆分的部分比较简单,如果我们再看看不同级别的查分,对于任意值的 t
,
P1
, Q1
, R1
, 红点构成一个曲线,终点是红点。
红点, R2
, Q3
, P4
构成一个曲线。换句话说我们可以将曲线从任意位置分成两段,
并且和原曲线相同。
第二个部分是如何决定曲线是否需要拆分,从网上查找后我发现了这个方法, 对于给定的曲线可以求出平滑程度。
function flatness(points, offset) {
const p1 = points[offset + 0];
const p2 = points[offset + 1];
const p3 = points[offset + 2];
const p4 = points[offset + 3];
let ux = 3 * p2[0] - 2 * p1[0] - p4[0]; ux *= ux;
let uy = 3 * p2[1] - 2 * p1[1] - p4[1]; uy *= uy;
let vx = 3 * p3[0] - 2 * p4[0] - p1[0]; vx *= vx;
let vy = 3 * p3[1] - 2 * p4[1] - p1[1]; vy *= vy;
if(ux < vx) {
ux = vx;
}
if(uy < vy) {
uy = vy;
}
return ux + uy;
}
我们可以用它获取曲线上的点,首先检查曲线是否太锐利,如果是就拆分, 不是就将点加入列表。
function getPointsOnBezierCurveWithSplitting(points, offset, tolerance, newPoints) {
const outPoints = newPoints || [];
if (flatness(points, offset) < tolerance) {
// 将它加入点队列中
outPoints.push(points[offset + 0]);
outPoints.push(points[offset + 3]);
} else {
// 拆分
const t = .5;
const p1 = points[offset + 0];
const p2 = points[offset + 1];
const p3 = points[offset + 2];
const p4 = points[offset + 3];
const q1 = v2.lerp(p1, p2, t);
const q2 = v2.lerp(p2, p3, t);
const q3 = v2.lerp(p3, p4, t);
const r1 = v2.lerp(q1, q2, t);
const r2 = v2.lerp(q2, q3, t);
const red = v2.lerp(r1, r2, t);
// 求前半段的点
getPointsOnBezierCurveWithSplitting([p1, q1, r1, red], 0, tolerance, outPoints);
// 求后半段的点
getPointsOnBezierCurveWithSplitting([red, r2, q3, p4], 0, tolerance, outPoints);
}
return outPoints;
}
这个算法在获取曲线点的过程中确保了点的数量比较充足,但是不能很好的排除不必要的点。
由于这个原因我们将使用我在网上找到的 Ramer Douglas Peucker 算法。
在这个算法中我们提供一系列点,找到离最后两点构成的直线距离最远的点, 然后将这个距离和一个定值进行比较,如果小于那个值就保留最后两个点然后丢弃其他的点, 大于则将曲线沿那个最远点分成两份,分别对每一份再做一次这个运算。
function simplifyPoints(points, start, end, epsilon, newPoints) {
const outPoints = newPoints || [];
// 找到离最后两点距离最远的点
const s = points[start];
const e = points[end - 1];
let maxDistSq = 0;
let maxNdx = 1;
for (let i = start + 1; i < end - 1; ++i) {
const distSq = v2.distanceToSegmentSq(points[i], s, e);
if (distSq > maxDistSq) {
maxDistSq = distSq;
maxNdx = i;
}
}
// 如果距离太远
if (Math.sqrt(maxDistSq) > epsilon) {
// 拆分
simplifyPoints(points, start, maxNdx + 1, epsilon, outPoints);
simplifyPoints(points, maxNdx, end, epsilon, outPoints);
} else {
// 添加最后两个点
outPoints.push(s, e);
}
return outPoints;
}
v2.distanceToSegmentSq
是计算点到线段距离平方的一个方法,
使用距离平方的原因是比使用实际距离要快一些,因为我们值管线最远距离所以和实际距离的效果相同。
这是结果,调整距离查看添加或删除的点。
回到保龄球瓶,我们可以将上方的代码整理一下,需要添加和移除点,锁定和解锁控制点, 撤销等等。但是这有一个简单的方式,我们可以使用一个上方提到的编辑器,我使用这个在线编辑器。
这是我做的保龄球的半边轮廓的 svg。
由 4 个曲线制成,路径的数据像这样
<path fill="none" stroke-width="5" d="
m44,434
c18,-33 19,-66 15,-111
c-4,-45 -37,-104 -39,-132
c-2,-28 11,-51 16,-81
c5,-30 3,-63 -36,-63
"/>
解译这些数据 得到这些点。
___
44, 371, |
62, 338, | 第一个曲线
63, 305,___|__
59, 260,___| |
55, 215, | 第二个曲线
22, 156,______|__
20, 128,______| |
18, 100, | 第三个曲线
31, 77,_________|__
36, 47,_________| |
41, 17, | 第四个曲线
39, -16, |
0, -16,____________|
现在有了曲线数据,需要计算出曲线上的点。
// 获取所有片段的点
function getPointsOnBezierCurves(points, tolerance) {
const newPoints = [];
const numSegments = (points.length - 1) / 3;
for (let i = 0; i < numSegments; ++i) {
const offset = i * 3;
getPointsOnBezierCurveWithSplitting(points, offset, tolerance, newPoints);
}
return newPoints;
}
调用 simplifyPoints
处理结果。
现在要旋转它们了,需要决定分多少个部分,对于每个部分都用矩阵运算 绕 Y 轴转动一定角度获得,一旦获得所有点就用索引连接它们。
// 绕 Y 轴旋转
function lathePoints(points,
startAngle, // 起始角 (例如 0)
endAngle, // 终止角 (例如 Math.PI * 2)
numDivisions, // 这中间生成多少块
capStart, // true 就封闭起点
capEnd) { // true 就封闭重点
const positions = [];
const texcoords = [];
const indices = [];
const vOffset = capStart ? 1 : 0;
const pointsPerColumn = points.length + vOffset + (capEnd ? 1 : 0);
const quadsDown = pointsPerColumn - 1;
// 生成点
for (let division = 0; division <= numDivisions; ++division) {
const u = division / numDivisions;
const angle = lerp(startAngle, endAngle, u) % (Math.PI * 2);
const mat = m4.yRotation(angle);
if (capStart) {
// 在开始处添加一个 Y 轴上的点
positions.push(0, points[0][1], 0);
texcoords.push(u, 0);
}
points.forEach((p, ndx) => {
const tp = m4.transformPoint(mat, [...p, 0]);
positions.push(tp[0], tp[1], tp[2]);
const v = (ndx + vOffset) / quadsDown;
texcoords.push(u, v);
});
if (capEnd) {
// 在终点处添加一个 Y 轴上的点
positions.push(0, points[points.length - 1][1], 0);
texcoords.push(u, 1);
}
}
// 创建索引
for (let division = 0; division < numDivisions; ++division) {
const column1Offset = division * pointsPerColumn;
const column2Offset = column1Offset + pointsPerColumn;
for (let quad = 0; quad < quadsDown; ++quad) {
indices.push(column1Offset + quad, column2Offset + quad, column1Offset + quad + 1);
indices.push(column1Offset + quad + 1, column2Offset + quad, column2Offset + quad + 1);
}
}
return {
position: positions,
texcoord: texcoords,
indices: indices,
};
}
上方的代码创建了位置点和纹理坐标,然后创建索引生成三角网。
capStart
和 capEnd
确定是都生成闭合点,假设我们在做一个罐头,
这些选项指明是否需要闭合顶面和底面。
使用我们的 简化代码 就可以用哪些数据生成这样的 WebGL 缓冲
const tolerance = 0.15;
const distance = .4;
const divisions = 16;
const startAngle = 0;
const endAngle = Math.PI * 2;
const capStart = true;
const capEnd = true;
const tempPoints = getPointsOnBezierCurves(curvePoints, tolerance);
const points = simplifyPoints(tempPoints, 0, tempPoints.length, distance);
const arrays = lathePoints(points, startAngle, endAngle, divisions, capStart, capEnd);
const extents = getExtents(arrays.position);
if (!bufferInfo) {
bufferInfo = webglUtils.createBufferInfoFromArrays(gl, arrays);
这是结果
拖动滑块观察对结果的影响。
这还有一个问题,开启三角形你会看到纹理不是均匀分布的,这是因为我们将纹理坐标的 v
值赋为曲线点的索引,如果曲线上的点距离相等那就没问题,但是它们距离并不相等。
我们可以遍历曲线上的点并计算出每一点对应曲线长度,最后将这个长度除以曲线总长度赋值给 v
。
// 绕 Y 轴旋转
function lathePoints(points,
startAngle, // 起始角 (例如 0)
endAngle, // 终止角 (例如 Math.PI * 2)
numDivisions, // 这中间生成多少块
capStart, // true 就封闭起点
capEnd) { // true 就封闭重点
const positions = [];
const texcoords = [];
const indices = [];
const vOffset = capStart ? 1 : 0;
const pointsPerColumn = points.length + vOffset + (capEnd ? 1 : 0);
const quadsDown = pointsPerColumn - 1;
+ // 生成 v 值
+ let vcoords = [];
+
+ // 先计算出每一点对应的长度
+ let length = 0;
+ for (let i = 0; i < points.length - 1; ++i) {
+ vcoords.push(length);
+ length += v2.distance(points[i], points[i + 1]);
+ }
+ vcoords.push(length); // 最后一个点
+
+ // 除以总长
+ vcoords = vcoords.map(v => v / length);
// 生成点
for (let division = 0; division <= numDivisions; ++division) {
const u = division / numDivisions;
const angle = lerp(startAngle, endAngle, u) % (Math.PI * 2);
const mat = m4.yRotation(angle);
if (capStart) {
// 在开始处添加一个 Y 轴上的点
positions.push(0, points[0][1], 0);
texcoords.push(u, 0);
}
points.forEach((p, ndx) => {
const tp = m4.transformPoint(mat, [...p, 0]);
positions.push(tp[0], tp[1], tp[2]);
* texcoords.push(u, vcoords[ndx]);
});
if (capEnd) {
// 在终点处添加一个 Y 轴上的点
positions.push(0, points[points.length - 1][1], 0);
texcoords.push(u, 1);
}
}
// 创建索引
for (let division = 0; division < numDivisions; ++division) {
const column1Offset = division * pointsPerColumn;
const column2Offset = column1Offset + pointsPerColumn;
for (let quad = 0; quad < quadsDown; ++quad) {
indices.push(column1Offset + quad, column1Offset + quad + 1, column2Offset + quad);
indices.push(column1Offset + quad + 1, column2Offset + quad + 1, column2Offset + quad);
}
}
return {
position: positions,
texcoord: texcoords,
indices: indices,
};
}
这是结果
这些纹理坐标还是不完美,因为我们还没决定怎么处理闭合部分的纹理。这也是使用建模软件的一个原因。 我们可以总结出很多计算闭合处 uv 值的方法,但并不是很有意义。 如果你 谷歌一下 UV map a barrel, 你会发现完美的 UV 坐标是不需要太多数学运算的,只需要生成合适的点数据,这时你就需要一个合适工具创建点数据。
还有一个事情要做,就是添加法向量。
我们可以计算每一个曲线点的法向量,事实上如果你会看这节中的例子,你会发现 R1
和 R2
构成的线段切曲线于红点处。
法向量和切线垂直所以从切线很容易求出法向量。
但是,假设我们想要做一个烛台,有这样一个框架。
这有很多平滑区域也有很多锐利角,如何决定使用法向量的方向呢?当需要锐利边缘时就要使用多余的顶点, 因为一个顶点有一个位置和一个法向量,如果需要多个法向量就需要不同的顶点,这也是制作立方体需要至少 24 个顶点的原因,虽然立方体只有 8 个顶点,但每个面在那个顶点处都需要不同的法向量。
创建立方体的时候很容易确定法向量,但是形状复杂的时候就没那么容易了。
所有的建模软件都有不同的方式创建法向量,一个常用的做法就是将该点邻接的三角面的法向量求平均。 另外,还允许用户选择一个最大角度,如果邻接的多边形的法向量的夹角大于最大角度,就会创建一个新顶点。
我们来实现这个。
function generateNormals(arrays, maxAngle) {
const positions = arrays.position;
const texcoords = arrays.texcoord;
// 首先计算出每个面的法向量
let getNextIndex = makeIndiceIterator(arrays);
const numFaceVerts = getNextIndex.numElements;
const numVerts = arrays.position.length;
const numFaces = numFaceVerts / 3;
const faceNormals = [];
// 计算每个面的法向量,
// 计算过程中为每个面新建顶点
for (let i = 0; i < numFaces; ++i) {
const n1 = getNextIndex() * 3;
const n2 = getNextIndex() * 3;
const n3 = getNextIndex() * 3;
const v1 = positions.slice(n1, n1 + 3);
const v2 = positions.slice(n2, n2 + 3);
const v3 = positions.slice(n3, n3 + 3);
faceNormals.push(m4.normalize(m4.cross(m4.subtractVectors(v1, v2), m4.subtractVectors(v3, v2))));
}
let tempVerts = {};
let tempVertNdx = 0;
// 假设顶点位置精确匹配
function getVertIndex(x, y, z) {
const vertId = x + "," + y + "," + z;
const ndx = tempVerts[vertId];
if (ndx !== undefined) {
return ndx;
}
const newNdx = tempVertNdx++;
tempVerts[vertId] = newNdx;
return newNdx;
}
// 我们需要算出共享的顶点
// 这并不像我们看着面那么简单 (三角形)
// 因为加入我们有一个标准的圆柱
//
//
// 3-4
// / \
// 2 5 从上往下看,从 S 走到 E, E 和 S
// 1 6 是不同的点,因为它们不共享UV坐标。
// \ /
// S/E
//
// 顶点在其实和结束位置并不是共享的
// 由于它们有不同的UV坐标,但如果不
// 把它们看作共享顶点就会得到错误结果
const vertIndices = [];
for (let i = 0; i < numVerts; ++i) {
const offset = i * 3;
const vert = positions.slice(offset, offset + 3);
vertIndices.push(getVertIndex(vert));
}
// 遍历所有顶点记录所在的面
const vertFaces = [];
getNextIndex.reset();
for (let i = 0; i < numFaces; ++i) {
for (let j = 0; j < 3; ++j) {
const ndx = getNextIndex();
const sharedNdx = vertIndices[ndx];
let faces = vertFaces[sharedNdx];
if (!faces) {
faces = [];
vertFaces[sharedNdx] = faces;
}
faces.push(i);
}
}
// 遍历面上的顶点计算每个顶点的法向量
// 只计算两面角度不大于 maxAngle 面
// 将结果写入 newPositions,
// newTexcoords 和 newNormals,
// 丢弃相同的顶点
tempVerts = {};
tempVertNdx = 0;
const newPositions = [];
const newTexcoords = [];
const newNormals = [];
function getNewVertIndex(x, y, z, nx, ny, nz, u, v) {
const vertId =
x + "," + y + "," + z + "," +
nx + "," + ny + "," + nz + "," +
u + "," + v;
const ndx = tempVerts[vertId];
if (ndx !== undefined) {
return ndx;
}
const newNdx = tempVertNdx++;
tempVerts[vertId] = newNdx;
newPositions.push(x, y, z);
newNormals.push(nx, ny, nz);
newTexcoords.push(u, v);
return newNdx;
}
const newVertIndices = [];
getNextIndex.reset();
const maxAngleCos = Math.cos(maxAngle);
// 对每个面
for (let i = 0; i < numFaces; ++i) {
// 获取该面的法向量
const thisFaceNormal = faceNormals[i];
// 对于面上的每一点
for (let j = 0; j < 3; ++j) {
const ndx = getNextIndex();
const sharedNdx = vertIndices[ndx];
const faces = vertFaces[sharedNdx];
const norm = [0, 0, 0];
faces.forEach(faceNdx => {
// 面的法向量是否相同
const otherFaceNormal = faceNormals[faceNdx];
const dot = m4.dot(thisFaceNormal, otherFaceNormal);
if (dot > maxAngleCos) {
m4.addVectors(norm, otherFaceNormal, norm);
}
});
m4.normalize(norm, norm);
const poffset = ndx * 3;
const toffset = ndx * 2;
newVertIndices.push(getNewVertIndex(
positions[poffset + 0], positions[poffset + 1], positions[poffset + 2],
norm[0], norm[1], norm[2],
texcoords[toffset + 0], texcoords[toffset + 1]));
}
}
return {
position: newPositions,
texcoord: newTexcoords,
normal: newNormals,
indices: newVertIndices,
};
}
function makeIndexedIndicesFn(arrays) {
const indices = arrays.indices;
let ndx = 0;
const fn = function() {
return indices[ndx++];
};
fn.reset = function() {
ndx = 0;
};
fn.numElements = indices.length;
return fn;
}
function makeUnindexedIndicesFn(arrays) {
let ndx = 0;
const fn = function() {
return ndx++;
};
fn.reset = function() {
ndx = 0;
}
fn.numElements = arrays.positions.length / 3;
return fn;
}
function makeIndiceIterator(arrays) {
return arrays.indices
? makeIndexedIndicesFn(arrays)
: makeUnindexedIndicesFn(arrays);
}
上方的代码首先通过原始顶点计算每个面(三角形)的法向量, 然后创建一个顶点索引集寻找相同的顶点,那是因为我们旋转后的起始和终止点应该是同一个点, 但 UV 坐标不同所以要单独处理,计算顶点法向量时要将它们看作相同点。
这些做完之后,对于每个顶点,生成了一个包含它的面的集合。
最后将所有除了差值大于 maxAngle
的面的法向量求平均,获得一个新的顶点集合。
这是结果
注意到在期望的位置得到了锐利的边缘,调大 maxAngle
的值就会将相邻的面加入计算,得到平滑的边缘。
试试调整 divisions
为 5 或者 6 然后调整 maxAngle
的值让该平滑的地方平滑,该锐利的地方锐利,
你也可以设置 mode
为 lit
查看光照效果,这是我们需要法向量的原因。
我们学到了如果想做三维模型就用三维建模库😝
你可能需要以个 UV 编辑器, 帮助完成封闭问题也是三维编辑器提供的功能。代替使用有限的组合处理闭合处问题, 可以使用其他编辑器提供的特性处理闭合处并轻松的获取 UV 值,三维编辑器还支持 拉伸面和 沿路径拉伸, 你看了之后就会发现它们基于上方的加工方式。
如果没有这篇出色的贝塞尔曲线文章 我就不可能完成这些内容。