如何用JavaScript编写3D建模应用程序





5.00/5 (15投票s)
在本文中,我将介绍如何在 JavaScript 和 WebGL 中实现一个 3D 细分曲面建模应用程序。
引言
Subsurfer 中的建模基于立方体,每个模型都从立方体开始。顶部的按钮用于选择当前工具。使用“实体”工具,您可以右键单击实体并更改其某些属性,例如颜色。使用“滑块”工具进行模型的平移、缩放和旋转。上下文菜单和颜色选择器是在 Canvas
控件中实现的。这个 3D 投影和所有模型编辑都是在 2D 上下文中完成的。
模型通过将连续的细分曲面应用于实体,并结合挤出和分割面来开发。界面是键盘命令与使用“实体”、“面”、“边”和“顶点”工具的右键菜单相结合。在这里,我们看到对立方体连续应用表面细分。
复选框控制查看选项。在这里,我们看到相同的模型,并勾选了“清除”和“轮廓”选项。
在这里,我们看到一个已被挤出的面。挤出是通过右键菜单项和键盘命令实现的。面是使用“面”工具选择的。您可以单击一个面,单击并拖动以选择多个面,或拖动一个框来选择多个面。
挤出面时的一个重要事项是避免出现公共内壁。当挤出多个法线方向相同的相邻面时,可能会发生这种情况。共享内壁会混淆 Catmull-Clark 算法,导致结果不正确。为避免此问题,在挤出相邻面时,除非它们的法线指向不同方向,否则最好使用“组挤出”命令。
边循环会影响表面细分如何塑造模型。可以通过使用“倒角”命令(面工具)或使用“分割”命令(边工具)来添加边循环。可以使用边工具的右键菜单选项来选择边循环。
Subsurfer 中的每个面都是一个四边形。Catmull-Clark 算法能很好地处理四边形,并且它们使得实现可以遍历模型以查找边循环和面循环的算法更加容易。
顶点工具可以用来拖动顶点,就像面工具可以拖动面一样,边工具可以拖动边。拖动模型元素时,显示网格(“网格”复选框选项)很重要,这样您就知道您在拖动哪两个维度。否则,结果可能会出乎意料且不受欢迎。
Subsurfer 有一个编辑窗口(2D 画布上下文)和一个视图窗口(3D 画布上下文)。它们由“编辑”和“视图”复选框控制。在这里,我们看到编辑窗口中的模型及其在视图窗口中的 WebGL 等效模型。
细分曲面建模会产生具有光滑圆角的形状。通过仔细规划和耐心编辑,可以通过挤出、分割、缩放和倾斜面、平移边和顶点,以及连续应用平滑算法来创建复杂的模型。
这是编辑窗口中 spacepig 模型的网格视图。与所有 Subsurfer 模型一样,它也是从立方体开始的。
Subsurfer 支持少量内置纹理,例如木纹(如下所示)。一个名为 textures.png 的图像文件包含所有纹理。
如果您想从文件系统运行程序,浏览器安全设置将不允许网页加载纹理图像。HTML 页面和 PNG 图像都必须托管在同一服务器上。如果您有合适的软件来设置,可以从 localhost 运行程序。或者,您可以运行 Chrome.exe 并带有一个特殊的命令行选项,以允许从文件系统加载纹理。您需要执行的命令是“chrome.exe --allow-file-access-from-files
”。在此之前,您必须关闭所有 Chrome 实例。
随附了各种纹理,包括下面看到的 mod 佩斯利。有一个“挤出系列”命令,可自动进行面的连续挤出,这有助于创造出令人迷幻、洛夫克拉夫特式的噩梦。
“源代码”命令(左侧按钮)会打开一个新标签页,显示当前模型网格的文本表示。
“保存”、“打开”和“删除”按钮已实现并使用 AJAX 调用进行了测试,以便将模型存储在服务器上并通过名称检索它们。但由于本文档的目的,我不想消耗我的服务器资源,因此我更改了路径和名称,使这些按钮不起作用。您仍然可以使用提供的 AJAX 代码,但需要自己实现 SOAP Web 服务,并更改客户端代码以匹配。
但是,您仍然可以通过复制“源代码”命令中的文本将模型保存到本地文件。如果您想将本地保存的模型输入到 Subsurfer 中,请使用“输入”按钮。它是侧边栏的命令之一,但在此图片中未显示。输入命令会弹出一个表单,您只需将网格文本粘贴到字段中,如下所示。对于大型模型,这似乎效果相当好。您可能会遇到浏览器安全设置问题,但对我来说工作正常。
随附了各种 WebGL 着色器,可从右上角的下拉菜单中选择。WebGL 中的着色器是使用 GLSL 实现的。平面着色和带有可选高光的 Phong(平滑)着色是最有用的。平面着色应用于棱角分明的物体。使用 Phong 着色时,立方体看起来很奇怪。我还实现了一些非真实的自定义着色器,包括下面图片中的节日彩虹着色器(这不是纹理,而是自定义着色器)。此着色器对对象在空间中的位置敏感,因此随着对象的旋转,颜色会以非常迷幻的方式变化。
程序内置了帮助文件和键盘命令列表(侧边栏最后的两个按钮),但开始使用 Subsurfer 最快的方法是尝试挤出面和使用键盘命令平滑实体,看看您能创建出什么样的奇怪而有趣的模型。挤出面的键盘命令是 'e
',平滑实体的键盘命令是 's
'。您需要选择“面”工具才能选择面。您可以使用“面”工具(以及大多数其他工具)通过右键单击并拖动来旋转模型。加号和减号键可用于放大或缩小。单击一个面以选择它。您还可以选择多个面,并单击+拖动来选择区域。可以同时挤出多个面。但是,如果进行多次挤出,请确保面部没有面向完全相同的方向,否则您将最终得到共享的内壁,这会扰乱细分算法。如果挤出面向相同方向的相邻面,最好使用“组挤出”(键盘命令 'g
')而不是。
Using the Code
您可以直接从本地文件系统运行 HTML 文件。如上所述,如果在本地运行,您会遇到安全问题,并且纹理将不会显示在 WebGL 中。
为解决此问题,请关闭所有 Chrome 实例,并使用以下命令启动 Chrome:“chrome.exe --allow-file-access-from-files
”。
另外,“保存”、“打开”和“删除”按钮基本上是禁用的。要保存模型,请使用“源代码”命令(侧边栏按钮)复制网格规范。要将保存的模型输入到 Subsurfer 中,请使用“输入”命令并将网格文本粘贴到提供的表单中。
挤出面时的一个重要事项是避免出现公共内壁。当挤出多个法线方向相同的相邻面时,可能会发生这种情况。内壁会破坏 Catmull-Clark 算法的结果。为避免此问题,在挤出相邻面时,除非它们的法线指向不同方向,否则最好使用“组挤出”命令。
构建编辑视图
该应用程序的代码量约有 14000 行。WebGL 部分使用了 James Coglan 的 Sylvester 矩阵数学库,该库是根据许可协议使用的。在本文中,我将简要介绍使程序运行的几个基本元素。我可能会在未来的文章中更深入地讨论一些主题。
本节介绍如何在 2D 绘图上下文中生成编辑视图的 3D 投影。
该程序使用了 HTML5 Canvas 控件,该控件有两个上下文。下面是初始化程序 UI 的函数。它添加了两个 Canvas
控件,并为其中一个获取 2D 上下文,为另一个获取 webgl (3D) 上下文。如果 webgl 不可用,它将回退到 experimental-webgl。WebGL 功能似乎在所有主要浏览器上都得到了很好的支持。其余代码设置用户输入的侦听器,并处理其他杂项,例如将可用的着色器选项添加到 listbox
。
function startModel()
{
alertUser("");
filename = "";
setInterval(timerEvent, 10);
makeCube();
canvas = document.createElement('canvas');
canvas2 = document.createElement('canvas');
document.body.appendChild(canvas);
document.body.appendChild(canvas2);
canvas.style.position = 'fixed';
canvas2.style.position = 'fixed';
ctx = canvas.getContext('2d');
gl = canvas2.getContext("webgl") || canvas2.getContext("experimental-webgl");
pos = new Point(0, 0); // last known position
lastClickPos = new Point(0, 0); // last click position
window.addEventListener('resize', resize);
window.addEventListener('keydown', keyDown);
window.addEventListener('keyup', keyRelease);
canvas.addEventListener('mousemove', mouseMove);
canvas.addEventListener('mousedown', mouseDown);
canvas.addEventListener('mouseup', mouseUp);
canvas.addEventListener('mouseenter', setPosition);
canvas.addEventListener('click', click);
canvas2.addEventListener('mousemove', mouseMoveGL);
canvas2.addEventListener('mousedown', mouseDownGL);
canvas2.addEventListener('mouseup', mouseUpGL);
canvas.style.backgroundColor = colorString(canvasBackgroundColor, false);
canvas.style.position = "absolute";
canvas.style.border = '1px solid black';
canvas2.style.position = "absolute";
canvas2.style.border = '1px solid black';
resize();
document.getElementById("checkboxoutlines").checked = false;
document.getElementById("checkboxsolid").checked = true;
document.getElementById("checkboxgrid").checked = false;
document.getElementById("toolslider").checked = true;
document.getElementById("checkboxtwosided").checked = true;
document.getElementById("checkboxwebgl").checked = false;
document.getElementById("checkbox2DWindow").checked = true;
document.getElementById("checkboxtransparent").checked = false;
if (gl != null)
{
gl.clearColor(canvasBackgroundColor.R / 255.0,
canvasBackgroundColor.G / 255.0, canvasBackgroundColor.B / 255.0, 1.0);
gl.enable(gl.DEPTH_TEST);
gl.depthFunc(gl.LEQUAL);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
}
addShaderToList("Phong");
addShaderToList("Rainbow 1");
addShaderToList("Rainbow 2");
addShaderToList("Stripes");
addShaderToList("Chrome");
addShaderToList("Smear");
addShaderToList("Flat");
addShaderToList("T-Map");
addShaderToList("Comic");
addShaderToList("Comic 2");
addShaderToList("Topo");
addShaderToList("Paint By Numbers");
var rect = canvas.getBoundingClientRect();
origin = new Point(-(rect.width / 2), -(rect.height / 2));
setEditViewOptions();
hideInputForm();
}
出于各种原因,程序中的所有编辑都在 2D 上下文中完成,因为在我看来,在 2D 上下文中解决与命中检测和用户交互相关的问题更容易。在 2D 上下文中绘制也比在 WebGL 中绘制简单得多。
要在 2D 中创建 3D 投影,只需要做几件事。下面是将在 2D 中映射 3D 点的投影代码。为此,只需想象一个 X/Y 平面,该平面沿 Z 轴位于模型和观察者眼睛之间。然后计算从眼睛到每个 3D 模型顶点的射线会与该平面相交的位置。
function To2D(p3d) // gives a 3D->2D perspective projection
{
var point3d = new Point3D(p3d.x, p3d.y, p3d.z);
RotateXYZ(point3d, myCenter, radiansX, radiansY, radiansZ);
var xRise = point3d.x - myCenter.x;
var yRise = point3d.y - myCenter.y;
var zRunEye = zEyePlane - point3d.z;
var zRunView = zViewingPlane - point3d.z;
var factor = (zRunEye - zRunView) / zRunEye;
var x = (myCenter.x + (factor * xRise));
var y = (myCenter.y + (factor * yRise));
x *= ctx.canvas.width;
x /= docSize;
y *= ctx.canvas.width;
y /= docSize;
var p = new Point(Math.floor(x), -Math.floor(y));
// have to flip sign of Y coordinate, this makes it match the GL side
p.x -= origin.x;
p.y -= origin.y;
return p;
}
请注意,上述函数执行的第一件事是将点从其实际位置旋转到当前观察位置。这是为了提供一种用户可以旋转工作并从各个角度查看它的方式。这也很简单,如下所示。每当用户输入鼠标输入来旋转视图时,变量 radiansX
、radiansY
和 radiansZ
就会更新,并且会重绘投影。
function RotateXYZ(p, rotation_point, radiansX, radiansY, radiansZ)
{
if (radiansZ != 0.0) // rotate about Z axis
{
radiansZ = normalize_radians(radiansZ);
if (radiansZ != 0)
{
var ydiff = (p.y) - (rotation_point.y);
var xdiff = (p.x) - (rotation_point.x);
var xd = (xdiff * Math.cos(radiansZ)) - (ydiff * Math.sin(radiansZ));
xd = Math.round(xd, 0);
var yd = (xdiff * Math.sin(radiansZ)) + (ydiff * Math.cos(radiansZ));
yd = Math.round(yd, 0);
p.x = rotation_point.x + (xd);
p.y = rotation_point.y + (yd);
}
}
if (radiansY != 0.0) // rotate about the Y axis
{
radiansY = normalize_radians(radiansY);
if (radiansY != 0)
{
var zdiff = (p.z) - (rotation_point.z);
var xdiff = (p.x) - (rotation_point.x);
var xd = (xdiff * Math.cos(radiansY)) - (zdiff * Math.sin(radiansY));
xd = Math.round(xd, 0);
var zd = (xdiff * Math.sin(radiansY)) + (zdiff * Math.cos(radiansY));
zd = Math.round(zd, 0);
p.x = rotation_point.x + (xd);
p.z = rotation_point.z + (zd);
}
}
if (radiansX != 0.0) // rotate about the X axis
{
radiansX = normalize_radians(radiansX);
if (radiansX != 0)
{
var ydiff = (p.y) - (rotation_point.y);
var zdiff = (p.z) - (rotation_point.z);
var zd = (zdiff * Math.cos(radiansX)) - (ydiff * Math.sin(radiansX));
zd = Math.round(zd, 0);
var yd = (zdiff * Math.sin(radiansX)) + (ydiff * Math.cos(radiansX));
yd = Math.round(yd, 0);
p.z = rotation_point.z + (zd);
p.y = rotation_point.y + (yd);
}
}
}
模型由面组成。面由边组成,边由点组成。下面是保存模型的基数数据结构。请注意,对于本程序而言,无论有多少个面,立方体仍然是一个立方体。每个模型都从一个具有 6 个面的立方体开始,但随着挤出、分割和平滑算法的应用,将有更多面添加到立方体中。
function cube(left, right, top, bottom, front, back)
{
if (left == undefined)
{
left = 0;
}
if (right == undefined)
{
right = 0;
}
if (top == undefined)
{
top = 0;
}
if (bottom == undefined)
{
bottom = 0;
}
if (front == undefined)
{
front = 0;
}
if (back == undefined)
{
back = 0;
}
this.color = new Color(190, 180, 190); // default solid color
this.outlineColor = new Color(0, 0, 0); // default solid outline color
this.textureName = "";
this.nSubdivide = 0;
this.left = left;
this.right = right;
this.top = top;
this.bottom = bottom;
this.front = front;
this.back = back;
this.previousFacetLists = [];
this.facets = [];
var lefttopback = new Point3D(left, top, back);
var lefttopfront = new Point3D(left, top, front);
var righttopfront = new Point3D(right, top, front);
var righttopback = new Point3D(right, top, back);
var leftbottomback = new Point3D(left, bottom, back);
var leftbottomfront = new Point3D(left, bottom, front);
var rightbottomfront = new Point3D(right, bottom, front);
var rightbottomback = new Point3D(right, bottom, back);
var topPoints = [];
topPoints.push(clonePoint3D(lefttopback));
topPoints.push(clonePoint3D(righttopback));
topPoints.push(clonePoint3D(righttopfront));
topPoints.push(clonePoint3D(lefttopfront));
topPoints.reverse();
var bottomPoints = [];
bottomPoints.push(clonePoint3D(leftbottomfront));
bottomPoints.push(clonePoint3D(rightbottomfront));
bottomPoints.push(clonePoint3D(rightbottomback));
bottomPoints.push(clonePoint3D(leftbottomback));
bottomPoints.reverse();
var frontPoints = [];
frontPoints.push(clonePoint3D(lefttopfront));
frontPoints.push(clonePoint3D(righttopfront));
frontPoints.push(clonePoint3D(rightbottomfront));
frontPoints.push(clonePoint3D(leftbottomfront));
frontPoints.reverse();
var backPoints = [];
backPoints.push(clonePoint3D(righttopback));
backPoints.push(clonePoint3D(lefttopback));
backPoints.push(clonePoint3D(leftbottomback));
backPoints.push(clonePoint3D(rightbottomback));
backPoints.reverse();
var leftPoints = [];
leftPoints.push(clonePoint3D(lefttopback));
leftPoints.push(clonePoint3D(lefttopfront));
leftPoints.push(clonePoint3D(leftbottomfront));
leftPoints.push(clonePoint3D(leftbottomback));
leftPoints.reverse();
var rightPoints = [];
rightPoints.push(clonePoint3D(righttopfront));
rightPoints.push(clonePoint3D(righttopback));
rightPoints.push(clonePoint3D(rightbottomback));
rightPoints.push(clonePoint3D(rightbottomfront));
rightPoints.reverse();
var id = 1;
var s1 = new Facet();
s1.ID = id++;
s1.points = topPoints;
this.facets.push(s1);
var s2 = new Facet();
s2.ID = id++;
s2.points = bottomPoints;
this.facets.push(s2);
var s3 = new Facet();
s3.ID = id++;
s3.points = backPoints;
this.facets.push(s3);
var s4 = new Facet();
s4.ID = id++;
s4.points = frontPoints;
this.facets.push(s4);
var s5 = new Facet();
s5.ID = id++;
s5.points = leftPoints;
this.facets.push(s5);
var s6 = new Facet();
s6.ID = id++;
s6.points = rightPoints;
this.facets.push(s6);
for (var n = 0; n < this.facets.length; n++)
{
this.facets[n].cube = this;
}
}
function Facet()
{
this.cube = -1;
this.ID = -1;
this.points = [];
this.point1 = new Point(0, 0);
this.point2 = new Point(0, 0);
this.closed = false;
this.fill = false;
this.averagePoint3D = new Point3D(0, 0, 0);
this.normal = -1;
this.edges = [];
this.neighbors = [];
this.greatestRotatedZ = 0;
this.greatestLeastRotatedZ = 0;
this.averageRotatedZ = 0;
this.boundsMin = new Point3D(0, 0, 0);
this.boundsMax = new Point3D(0, 0, 0);
}
function Point3D(x, y, z)
{
this.x = x;
this.y = y;
this.z = z;
}
要在 2D 中绘制模型,只需将每个面描述的多边形从 3D 映射到 2D,然后填充生成的多边形即可。只有两种复杂情况。第一种是每个面必须根据其相对于代表光源的向量的角度进行着色。第二种是面必须根据其在 Z 轴上的位置,相对于当前视图旋转进行从后到前的排序。这样,后面的面先绘制,前面的面则遮挡它们,这正是您想要的。
需要注意的是,通过沿 Z 轴对多边形进行排序来描绘实体的这种方法是一种近似。它没有考虑面之间的交叉。此外,当物体包含凹陷时,Z 排序可能会产生看起来不正确的結果。但是,当物体没有凹陷且表面没有交叉时,该方法可以产生足够好的结果。当面相对于模型尺寸较小时,例如在应用平滑后,异常的发生会大大减少。在存在异常的情况下,您可以通过旋转模型和/或使用“清除”和“轮廓”查看选项,并将模型视为带有透明表面的线框来在编辑过程中解决它们。这种性质的任何异常都不会出现在视图窗口中,因为 WebGL 可以正确处理所有这些情况。
要为多边形着色,需要获取其法线。这是垂直于面表面的向量(使用叉积计算)。计算该法线与光源向量之间的角度(使用点积),并以此来加亮或变暗面颜色。如果角度接近 0,则面颜色会变亮。如果角度接近 180,则面颜色会变暗。下面是计算面法线并为面着色的代码。
function CalculateNormal(facet)
{
var normal = -1;
if (facet.points.length > 2)
{
var p0 = facet.points[0];
var p1 = facet.points[1];
var p2 = facet.points[2];
var a = timesPoint(minusPoints(p1, p0), 8);
var b = timesPoint(minusPoints(p2, p0), 8);
normal = new line(clonePoint3D(p0),
new Point3D((a.y * b.z) - (a.z * b.y), // cross product
-((a.x * b.z) - (a.z * b.x)),
(a.x * b.y) - (a.y * b.x))
);
normal.end = LengthPoint(normal, cubeSize * 2);
var avg = averageFacetPoint(facet.points);
normal.end.x += avg.x - normal.start.x;
normal.end.y += avg.y - normal.start.y;
normal.end.z += avg.z - normal.start.z;
normal.start = avg;
}
return normal;
}
function getLightSourceAngle(normal)
{
var angle = 0;
if (normal != -1)
{
angle = normalize_radians(vectorAngle
(lightSource, minusPoints(ToRotated(normal.end), ToRotated(normal.start))));
}
return angle;
}
function vectorAngle(vector1, vector2)
{
var angle = 0.0;
var length1 = Math.sqrt((vector1.x * vector1.x) + (vector1.y * vector1.y) +
(vector1.z * vector1.z));
var length2 = Math.sqrt((vector2.x * vector2.x) + (vector2.y * vector2.y) +
(vector2.z * vector2.z));
var dot_product = (vector1.x * vector2.x + vector1.y * vector2.y + vector1.z * vector2.z);
var cosine_of_angle = dot_product / (length1 * length2);
angle = Math.acos(cosine_of_angle);
return angle;
}
function ShadeFacet(color, angle)
{
var darken_range = 0.75;
var lighten_range = 0.75;
var result = new Color(color.R, color.G, color.B);
if (angle > 180)
{
angle = 360 - angle;
}
if (angle > 90) // darken
{
var darken_amount = (angle - 90) / 90;
darken_amount *= darken_range;
var r = color.R - (color.R * darken_amount);
var g = color.G - (color.G * darken_amount);
var b = color.B - (color.B * darken_amount);
r = Math.min(255, Math.max(0, r));
g = Math.min(255, Math.max(0, g));
b = Math.min(255, Math.max(0, b));
result = new Color(r, g, b);
}
else // lighten
{
var lighten_amount = (90 - angle) / 90;
lighten_amount *= lighten_range;
var r = color.R + ((255 - color.R) * lighten_amount);
var g = color.G + ((255 - color.G) * lighten_amount);
var b = color.B + ((255 - color.B) * lighten_amount);
r = Math.max(0, Math.min(255, r));
g = Math.max(0, Math.min(255, g));
b = Math.max(0, Math.min(255, b));
result = new Color(r, g, b);
}
return result;
}
一旦面被着色,就需要将它们从后到前排序,这样当您按顺序绘制它们时,最近的面就会覆盖后面的面。
function sortFacets()
{
allFacets = [];
for (var w = 0; w < cubes.length; w++)
{
var cube = cubes[w];
for (var i = 0; i < cube.facets.length; i++)
{
allFacets.push(cube.facets[i]);
}
}
sortFacetsOnZ(allFacets);
}
function sortFacetsOnZ(facets)
{
for (var i = 0; i < facets.length; i++)
{
setAverageAndGreatestRotatedZ(facets[i]);
}
facets.sort(
function(a, b)
{
if (a.greatestRotatedZ == b.greatestRotatedZ)
{
if (a.leastRotatedZ == b.leastRotatedZ)
{
return a.averageRotatedZ - b.averageRotatedZ;
}
else
{
return a.leastRotatedZ - b.leastRotatedZ;
}
}
else
{
return a.greatestRotatedZ - b.greatestRotatedZ
}
}
);
}
以下是绘制带有 2D 上下文中 3D 投影的编辑显示的一些代码。这里基本发生的事情是 sortFacets()
和 drawCubes()
。这就是产生产生实体形状错觉的 3D 投影的原因。这里的其他代码与更新 WebGL 视图和绘制编辑 UI 的元素有关。编辑 UI 元素包括矩形方向网格和上下文菜单,以及模型元素(面、边、顶点),这些元素会受到鼠标悬停行为和高亮显示行为的影响,并且必须根据当前工具和鼠标位置以不同的颜色重绘。
function updateModel()
{
for (var c = 0; c < cubes.length; c++)
{
updateCube(cubes[c]);
}
sortFacets();
reloadSceneGL();
draw();
}
function draw()
{
if (isGL && gl != null)
{
drawSceneGL();
}
if (is2dWindow || !isGL)
{
ctx.clearRect(0, 0, canvas.width, canvas.height);
findGridOrientation();
if (gridChosen())
{
drawGridXY();
}
lineColor = lineColorShape;
drawCubes();
if (mouseIsDown && draggingShape)
{
draw3DRectangleFrom2DPoints(mouseDownPos, pos, false, "white");
}
if (hitLine != -1)
{
var pts = [];
pts.push(To2D(hitLine.start));
pts.push(To2D(hitLine.end));
drawPolygonHighlighted(pts);
}
if (hitFacet != -1 && toolChosen() == "facet")
{
drawPolygon3d(hitFacet.points, true, true, "yellow", true);
}
for (var g = 0; g < selectedLines.length; g++)
{
var pts = [];
pts.push(To2D(selectedLines[g].start));
pts.push(To2D(selectedLines[g].end));
drawPolygonSelected(pts);
}
if (hitVertex != -1)
{
drawVertex(hitVertex, false);
}
for (var qq = 0; qq < selectedVertexes.length; qq++)
{
drawVertex(selectedVertexes[qq], true);
}
if (lineDiv != -1 &&
lineDiv2 != -1)
{
drawLine2D(lineDiv, "blue");
drawLine2D(lineDiv2, "blue");
}
if (draggingRect)
{
draw2DRectangleFrom2DPoints(mouseDownPos, pos, "black");
}
if (colorPickMode.length > 0)
{
drawColors(0, 0, colorPickHeight);
}
drawMenu();
}
}
function drawCubes()
{
var drawlines = isOutline || !isShade;
var drawNormals = isNormals;
var shadeSolids = isShade;
var dual = isDualSided;
for (var i = 0; i < allFacets.length; i++)
{
var facet = allFacets[i];
if (facet.normal == -1)
{
facet.normal = CalculateNormal(facet);
}
var c = facet.cube.color;
if (colorPickMode.length == 0)
{
if (facet.cube == hitSolid)
{
c = new Color(23, 100, 123);
}
if (listHas(selectedSolids, facet.cube))
{
c = new Color(200, 30, 144);
}
if (listHas(selectedFacets, facet))
{
c = new Color(0, 255, 255);
}
}
c = ShadeFacet(c, degrees_from_radians(getLightSourceAngle(facet.normal)));
var show = true;
if (!dual)
{
show = ShowFacet(degrees_from_radians(getFrontSourceAngle(facet.normal)));
}
var colorFillStyle = colorString(c, isTransparent);
var colorOutlineStyle = colorString(facet.cube.outlineColor, isTransparent);
if (listHas(selectedSolids, facet.cube))
{
drawlines = true;
colorOutlineStyle = "red";
}
if (show)
{
drawPolygon3d(facet.points, true, shadeSolids || listHas(selectedFacets, facet),
colorFillStyle, drawlines, colorOutlineStyle);
if (drawNormals)
{
drawLine3D(facet.normal, "magenta");
}
}
}
}
function drawPolygon3d(points, isClosed, isFill, fillColor, isOutline, outlineColor)
{
var result = [];
if (points.length > 0)
{
for (var i = 0; i < points.length; i++)
{
result.push(To2D(points[i]));
}
drawPolygon(result, isClosed, isFill, fillColor, isOutline, outlineColor);
}
}
function drawPolygon
(points, isClosed, isFill, fillColor, isOutline, outlineColor, lineThickness)
{
if (points.length > 0)
{
isClosed = isClosed ? isClosed : false;
isFill = isFill ? isFill : false;
if (isOutline === undefined)
{
isOutline = true;
}
if (lineThickness === undefined)
{
lineThickness = 1;
}
if (outlineColor === undefined)
{
outlineColor = lineColor;
}
ctx.beginPath();
ctx.lineWidth = lineThickness;
ctx.lineCap = 'round';
ctx.strokeStyle = outlineColor;
if (isFill)
{
ctx.fillStyle = fillColor;
}
ctx.moveTo(points[0].x, points[0].y);
for (var i = 1; i < points.length; i++)
{
ctx.lineTo(points[i].x, points[i].y);
}
if (isClosed)
{
ctx.lineTo(points[0].x, points[0].y);
}
if (isFill)
{
ctx.fill();
}
if (isOutline)
{
ctx.stroke();
}
}
}
构建 WebGL 模型
因此,2D 编辑视图的生成相当直接。WebGL 视图的生成要困难一些,将在未来的文章中更深入地讨论。我只会展示一些将我们的 JavaScript 数据结构绑定到模型 WebGL 表示形式的代码。有五个基本元素必须缓冲并绑定到 WebGL。下面是执行该工作的主要函数。
function bindModelGL()
{
bindVerticesGL();
bindColorsGL();
bindVertexIndicesGL();
bindTextureCoordinatesGL();
bindNormalsGL();
}
将颜色绑定到我们的模型。每个立方体只能有一种颜色。每个面都有一个指向其父立方体的指针。请注意,为了我们的目的,立方体只是一个面列表,它可能是一个实际的立方体,也可能不是。所有面的列表将为我们提供每个顶点的正确颜色。我们每个顶点需要 4 个元素:R、G、B 和 A(表示透明度的 Alpha 通道)。我们对 A 使用 1.0,因此我们的 WebGL 模型将始终是不透明的。
function bindColorsGL()
{
if (isGL && gl != null)
{
var generatedColors = [];
for (var i = 0; i < allFacets.length; i++)
{
var f = allFacets[i];
var c = color2FromColor(f.cube.color);
var b = [];
b.push(c.R);
b.push(c.G);
b.push(c.B);
b.push(1.0);
// repeat each color 4 times for the 4 vertices of each facet
for (var s = 0; s < 4; s++)
{
generatedColors.push(b[0]);
generatedColors.push(b[1]);
generatedColors.push(b[2]);
generatedColors.push(b[3]);
}
}
cubeVerticesColorBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, cubeVerticesColorBuffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(generatedColors), gl.STATIC_DRAW);
}
}
我们必须绑定面法线,以便 WebGL 可以对模型进行着色。请注意,对于每个面法线,我们只需要 3 个数字。这是因为 WebGL 只关心法线的方向,而不关心它在空间中的位置。
这里的一个特定细节是,Subsurfer 支持 Phong 着色,它需要顶点法线。如果您将每个面法线视为垂直于面表面,那么顶点法线是包含该顶点的所有面的法线的平均值。因此,当启用 Phong 着色时,必须计算顶点法线。我们在 2D 投影中不使用这些,因为我们只做平面着色,所以我们只需要面法线。但在 WebGL 中进行 Phong 着色时需要顶点法线。如果我们在 WebGL 中进行平面着色,那么我们不必计算顶点法线。在平面着色的情况下,我们只需将面法线用作每个顶点的法线。
function bindNormalsGL()
{
if (isGL && gl != null)
{
cubeVerticesNormalBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, cubeVerticesNormalBuffer);
var vertexNormals = [];
for (q = 0; q < allFacets.length; q++)
{
var f = allFacets[q];
if (f.normal == -1)
{
f.normal = CalculateNormal(f);
}
}
if (fastVertexNormalMethod)
{
if (isSmoothShading())
{
allSortedPoints = getFacetPointsAndSetUpBackPointers(allFacets);
sortPointsByXYZ(allSortedPoints);
stageVertexNeighborFacets(allSortedPoints);
}
}
if (isSmoothShading())
{
for (q = 0; q < allFacets.length; q++)
{
var f = allFacets[q];
for (var j = 0; j < f.points.length; j++)
{
var p = f.points[j];
var vn = p.vertexNormal;
if (vn == undefined)
{
vn = calculateVertexNormal(p, allFacets);
p.vertexNormal = vn;
}
vertexNormals.push((vn.end.x / reductionFactor) -
(vn.start.x / reductionFactor));
vertexNormals.push((vn.end.y / reductionFactor) -
(vn.start.y / reductionFactor));
vertexNormals.push((vn.end.z / reductionFactor) -
(vn.start.z / reductionFactor));
}
}
}
else
{
for (q = 0; q < allFacets.length; q++)
{
var f = allFacets[q];
for (var i = 0; i < 4; i++)
{
vertexNormals.push((f.normal.end.x / reductionFactor) -
(f.normal.start.x / reductionFactor));
vertexNormals.push((f.normal.end.y / reductionFactor) -
(f.normal.start.y / reductionFactor));
vertexNormals.push((f.normal.end.z / reductionFactor) -
(f.normal.start.z / reductionFactor));
}
}
}
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertexNormals), gl.STATIC_DRAW);
}
}
我们必须绑定模型中的每个顶点。即使 WebGL 需要三角形而不是四边形才能正常工作,顶点也无需重复,因为我们将提供一个指向顶点缓冲区的索引列表。其中一些索引将被重复,这将为我们提供三角形。
function bindVerticesGL()
{
cubeVerticesBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, cubeVerticesBuffer);
var vertices = [];
for (var i = 0; i < allFacets.length; i++)
{
var f = allFacets[i];
for (var j = 0; j < f.points.length; j++)
{
var point3d = f.points[j];
vertices.push(point3d.x / reductionFactor);
vertices.push(point3d.y / reductionFactor);
vertices.push((point3d.z / reductionFactor));
}
}
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.STATIC_DRAW);
}
在这里,我们构建顶点索引缓冲区并将其绑定到 WebGL。索引模式 0, 1, 2 然后是 0, 2, 3 将我们的四个面顶点分成两个三角形。
function bindVertexIndicesGL()
{
cubeVerticesIndexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, cubeVerticesIndexBuffer);
var cubeVertexIndices = [];
var t = 0;
for (var i = 0; i < allFacets.length; i++)
{
cubeVertexIndices.push(t + 0);
cubeVertexIndices.push(t + 1);
cubeVertexIndices.push(t + 2);
cubeVertexIndices.push(t + 0);
cubeVertexIndices.push(t + 2);
cubeVertexIndices.push(t + 3);
t += 4;
}
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(cubeVertexIndices), gl.STATIC_DRAW);
}
我们模型中的每个顶点都有 X、Y、Z 用于空间位置,以及另外两个坐标 U 和 V,它们是纹理图像的偏移量。U 和 V 值范围在 0 到 1 之间。对于复杂的形状,我们自动分配 U 和 V 坐标,就好像纹理环绕在图像周围一样。这是由函数 assignPolarUV_2()
完成的。
function bindTextureCoordinatesGL()
{
for (var i = 0; i < cubes.length; i++)
{
assignPolarUV_2(cubes[i], i);
}
cubeVerticesTextureCoordBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, cubeVerticesTextureCoordBuffer);
var textureCoordinates = [];
for (var i = 0; i < allFacets.length; i++)
{
if (isPolarUV)
{
var f = allFacets[i];
textureCoordinates.push(f.points[0].u);
textureCoordinates.push(f.points[0].v);
textureCoordinates.push(f.points[1].u);
textureCoordinates.push(f.points[1].v);
textureCoordinates.push(f.points[2].u);
textureCoordinates.push(f.points[2].v);
textureCoordinates.push(f.points[3].u);
textureCoordinates.push(f.points[3].v);
}
else
{
textureCoordinates.push(0.0);
textureCoordinates.push(0.0);
textureCoordinates.push(1.0);
textureCoordinates.push(0.0);
textureCoordinates.push(1.0);
textureCoordinates.push(1.0);
textureCoordinates.push(0.0);
textureCoordinates.push(1.0);
}
}
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(textureCoordinates),
gl.STATIC_DRAW);
}
平面着色器
在直接处理 WebGL 时,有必要编写自己的着色器。这些是用一种称为 GLSL 的语言编写的。每个着色器都必须有一个 main()
过程。着色器是一个小程序,它会在您的计算机图形芯片上进行编译和加载。
着色器包含在 HTML 文件中的 script
标签中,可以通过名称进行寻址。如果您使用的模型是纹理而不是纯色,则需要不同的着色器。Subsurfer 包含适用于颜色和纹理的平面着色器,以及适用于颜色和纹理的 Phong(平滑)着色器。还包含几个奇特的自定义着色器。在这里,我将仅提及纯色情况下的平面着色器和 Phong 着色器。
以下是纯色着色器。您必须提供一个顶点着色器和一个片段着色器。这是您实现的每种着色类型的要求。顶点着色器为您提供每个顶点的颜色。片段着色器可以在顶点之间进行插值,以创建更平滑的外观。它基本上对每个单独的像素进行着色。
下面的平面着色器提供的外观与我们在 JavaScript 中构建的、显示在编辑窗口中的 3D 投影非常相似。如果您看看这个顶点着色器在做什么,它实际上与我们在 JavaScript 中的 shadeFacet()
函数中之前进行的计算相同。它获取顶点法线(在这种情况下,与面法线相同)与光源方向向量之间的角度(点积),并以此来加亮或变暗面颜色。但是着色器可以在硬件上更快地完成,因为它运行在高度并行的设备上。此外,它还考虑了光的颜色,以及定向光和环境光。请注意,在此着色器中,光的颜色和方向是硬编码的。
这里的片段着色器没做什么太多事情,它只是一个直通。这是因为平面着色器没有插值或平滑,所以面上的所有像素都可以着色为相同的颜色。
<script id="vertex-shader-color-flat" type="x-shader/x-vertex">
// VERTEX SHADER COLOR (FLAT)
attribute highp vec3 aVertexNormal;
attribute highp vec3 aVertexPosition;
attribute vec4 aVertexColor;
uniform highp mat4 uNormalMatrix;
uniform highp mat4 uMVMatrix;
uniform highp mat4 uPMatrix;
varying highp vec3 vLighting;
varying lowp vec4 vColor;
void main(void) {
gl_Position = uPMatrix * uMVMatrix * vec4(aVertexPosition, 1.0);
highp vec3 ambientLight = vec3(0.5, 0.5, 0.5);
highp vec3 directionalLightColor = vec3(0.5, 0.5, 0.5);
highp vec3 directionalVector = vec3(0.85, 0.8, 0.75);
highp vec4 transformedNormal = uNormalMatrix * vec4(aVertexNormal, 1.0);
highp float directional = max(dot(transformedNormal.xyz, directionalVector), 0.0);
vLighting = ambientLight + (directionalLightColor * directional);
vColor = aVertexColor;
}
</script>
<script id="fragment-shader-color-flat" type="x-shader/x-fragment">
// FRAGMENT SHADER COLOR (FLAT)
varying lowp vec4 vColor;
varying highp vec3 vLighting;
uniform sampler2D uSampler;
void main(void) {
gl_FragColor = vec4(vColor.rgb * vLighting, 1.0);
}
</script>
Phong 着色器
Phong 着色通过在顶点之间进行插值来单独着色每个像素,从而提供更平滑的外观。下面的颜色 Phong 着色器。
请注意,顶点着色器这里没做什么太多事情。大部分操作都发生在片段着色器中,因为我们将计算每个像素。关于顶点着色器最有趣的一点是,转换后的顶点法线被声明为“varying”。这将导致它在片段着色器中为每个像素进行平滑插值。
因此,这个片段着色器实际上使用了不同的法线来处理每个像素。您看不到任何明确的代码来执行此操作,因为它内置于 GLSL 语言和“varying”类型中。与平面着色器一样,环境光和定向光的颜色以及光的 D 向都是硬编码的。此外,通过使用光 D 向向量和顶点法线之间的角度来计算颜色与平面着色器非常相似。这里的区别在于,计算发生在片段着色器中,使用不同的插值法线值来处理每个像素。这就是产生平滑外观的原因。Phong 着色器比平面着色器慢,因为它需要进行更多的计算。
关于 Phong 着色器要说的最后一点是,我已经实现了高光。如果 UI 上的“高光”复选框被选中,则 uniform 值 specularUniform
将被设置为 1
。如果发生这种情况,当光源与顶点法线之间的角度足够小时,该像素的颜色将自动设置为白色。这会产生高光,使模型看起来闪亮。
<script id="shader-vs-normals-notexture-phong" type="x-shader/x-vertex">
// VERTEX SHADER COLOR (PHONG)
attribute highp vec3 aVertexNormal;
attribute highp vec3 aVertexPosition;
attribute vec4 aVertexColor;
uniform highp mat4 uNormalMatrix;
uniform highp mat4 uMVMatrix;
uniform highp mat4 uPMatrix;
varying vec3 vTransformedNormal;
varying vec4 vPosition;
varying lowp vec4 vColor;
void main(void)
{
vPosition = uMVMatrix * vec4(aVertexPosition, 1.0);
gl_Position = uPMatrix * vPosition;
vTransformedNormal = vec3(uNormalMatrix * vec4(aVertexNormal, 1.0));
vColor = aVertexColor;
}
</script>
<script id="shader-fs-normals-notexture-phong" type="x-shader/x-fragment">
// FRAGMENT SHADER COLOR (PHONG)
precision mediump float;
uniform int specularUniform;
varying vec3 vTransformedNormal;
varying vec4 vPosition;
varying lowp vec4 vColor;
void main(void) {
vec3 pointLightingLocation;
pointLightingLocation = vec3(0, 13.5, 13.5);
vec3 ambientColor;
ambientColor = vec3(0.5, 0.5, 0.5);
vec3 pointLightingColor;
pointLightingColor = vec3(0.5, 0.5, 0.5);
vec3 lightWeighting;
vec3 lightDirection = normalize(pointLightingLocation - vPosition.xyz);
float directionalLightWeighting = max(dot(normalize(vTransformedNormal),
lightDirection), 0.0);
lightWeighting = ambientColor + pointLightingColor * directionalLightWeighting;
vec4 fragmentColor;
fragmentColor = vColor;
gl_FragColor = vec4(fragmentColor.rgb * lightWeighting, fragmentColor.a);
if (specularUniform == 1)
{
if (dot(normalize(vTransformedNormal), lightDirection) > 0.99) // specular
{
gl_FragColor = vec4(1.0, 1.0, 1.0, 1.0);
}
}
}
</script>
细分曲面算法
我本打算多说一些关于 Catmull-Clark 细分曲面算法以及我在 JavaScript 中的实现,但这篇文章已经太长了,我将在以后的文章中讨论。但是,如果您查看代码,您可以看到正在发生什么。我只会说大部分操作发生在名为 subdivisionSurfaceProcessFacet()
的函数中,该函数通过计算加权平均值(称为质心)来完成细分单个面的工作。算法之所以分为三个函数,使用计时器,是因为我可以在屏幕底部绘制一个进度条。由于 JavaScript 中没有真正的线程,我不得不这样做。该算法获取一个面列表,并用一个新列表替换它,在新列表中,每个面都被四个新面替换。请注意,当模型有孔洞时必须小心。位于此类孔洞边界上的面被视为特殊情况。
function startSubdivision(solid)
{
informUser("Subdividing, please wait...");
subdivSurfaceLoopCounter = 0;
var facets = solid.facets;
solidToSubdivide = solid;
isSubdividing = true;
if (solid.nSubdivide == 0)
{
solid.previousFacetLists.push(solid.facets);
}
for (var i = 0; i < facets.length; i++)
{
facets[i].edges = getFacetLines(facets[i]);
facets[i].averagePoint3D = averageFacetPoint(facets[i].points);
}
findFacetNeighborsAndAdjacents(facets);
for (var i = 0; i < facets.length; i++)
{
var facet = facets[i];
for (var j = 0; j < facet.edges.length; j++)
{
var edge = facet.edges[j];
var list = [];
list.push(edge.start);
list.push(edge.end);
if (edge.parentFacet != -1 && edge.adjacentFacet != -1)
{
list.push(edge.parentFacet.averagePoint3D);
list.push(edge.adjacentFacet.averagePoint3D);
}
edge.edgePoint = averageFacetPoint(list);
}
}
subdivTimerId = setTimeout(subdivisionSurfaceProcessFacet, 0);
newSubdivFacets = [];
}
function subdivisionSurfaceProcessFacet()
{
var facet = solidToSubdivide.facets[subdivSurfaceLoopCounter];
var nEdge = 0;
var neighborsAndCorners = facetNeighborsPlusFacet(facet);
for (var j = 0; j < facet.points.length; j++)
{
var p = facet.points[j];
var facepoints = [];
var edgepoints = [];
var facetsTouchingPoint = findFacetsTouchingPoint(p, neighborsAndCorners);
for (var n = 0; n < facetsTouchingPoint.length; n++)
{
var f = facetsTouchingPoint[n];
facepoints.push(averageFacetPoint(f.points));
}
var edgesTouchingPoint = findEdgesTouchingPoint(p, facetsTouchingPoint);
for (var m = 0; m < edgesTouchingPoint.length; m++)
{
var l = edgesTouchingPoint[m];
edgepoints.push(midPoint3D(l.start, l.end));
}
var onBorder = false;
if (facepoints.length != edgepoints.length)
{
onBorder = true; // vertex is on a border
}
var F = averageFacetPoint(facepoints);
var R = averageFacetPoint(edgepoints);
var n = facepoints.length;
var barycenter = roundPoint(divPoint(plusPoints
(plusPoints(F, timesPoint(R, 2)), timesPoint(p, n - 3)), n));
var n1 = nEdge;
if (n1 > facet.edges.length - 1)
{
n1 = 0;
}
var n2 = n1 - 1;
if (n2 < 0)
{
n2 = facet.edges.length - 1;
}
if (onBorder)
{
var borderAverage = [];
var etp = edgesTouchingPoint;
for (var q = 0; q < etp.length; q++)
{
var l = etp[q];
if (lineIsOnBorder(l))
{
borderAverage.push(midPoint3D(l.start, l.end));
}
}
borderAverage.push(clonePoint3D(p));
barycenter = averageFacetPoint(borderAverage);
}
var newFacet = new Facet();
newFacet.points.push(clonePoint3D(facet.edges[n2].edgePoint));
newFacet.points.push(clonePoint3D(barycenter));
newFacet.points.push(clonePoint3D(facet.edges[n1].edgePoint));
newFacet.points.push(clonePoint3D(facet.averagePoint3D));
newSubdivFacets.push(newFacet);
newFacet.cube = solidToSubdivide;
nEdge++;
}
drawThermometer(solidToSubdivide.facets.length, subdivSurfaceLoopCounter);
subdivSurfaceLoopCounter++;
if (subdivSurfaceLoopCounter >= solidToSubdivide.facets.length)
{
clearInterval(subdivTimerId);
finishSubdivision(solidToSubdivide);
}
else
{
subdivTimerId = setTimeout(subdivisionSurfaceProcessFacet, 0);
}
}
function finishSubdivision(parentShape)
{
parentShape.nSubdivide++;
parentShape.facets = newSubdivFacets;
fuseFaster(parentShape);
selectedFacets = [];
selectedLines = [];
selectedVertexes = [];
sortFacets();
setFacetCount(parentShape);
isSubdividing = false;
alertUser("");
reloadSceneGL();
draw();
}
历史
- 2020 年 6 月 29 日:初始版本