说明
【跟月影学可视化】学习笔记。
如何用 WebGL 绘制三维立方体
我们知道立方体有8个顶点,6个面,在 WebGL 中,需要用 12 个三角形来绘制它。把每个面的顶点分开,需要 24 个顶点。
绘制 3D 图形与绘制 2D 图形有一点不一样,必须要开启深度检测和启用深度缓冲区。
在 WebGL 中,可以通过 gl.enable(gl.DEPTH_TEST),来开启深度检测。
在清空画布的时候,也要用 gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT),来同时清空颜色缓冲区和深度缓冲区。
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>如何用 WebGL 绘制三维立方体</title> <style> canvas { border: 1px dashed salmon; } </style> </head> <body> <canvas width="512" height="512"></canvas> <script src="./common/lib/gl-renderer.js"></script> <script type="module"> const vertex = ` attribute vec3 a_vertexPosition; attribute vec4 color; varying vec4 vColor; void main() { gl_PointSize = 1.0; vColor = color; gl_Position = vec4(a_vertexPosition, 1); } `; const fragment = ` #ifdef GL_ES precision highp float; #endif varying vec4 vColor; void main() { gl_FragColor = vColor; } `; const canvas = document.querySelector("canvas"); // 开启深度检测 const renderer = new GlRenderer(canvas, { depth: true }); const program = renderer.compileSync(fragment, vertex); renderer.useProgram(program); // 用来生成立方体 6 个面的 24 个顶点,以及 12 个三角形的索引 function cube(size = 1.0, colors = [[1, 0, 0, 1]]) { const h = 0.5 * size; // 立方体的顶点 const vertices = [ [-h, -h, -h], [-h, h, -h], [h, h, -h], [h, -h, -h], [-h, -h, h], [-h, h, h], [h, h, h], [h, -h, h], ]; const positions = []; const color = []; const cells = []; let colorIdx = 0; let cellsIdx = 0; const colorLen = colors.length; function quad(a, b, c, d) { [a, b, c, d].forEach((i) => { positions.push(vertices[i]); color.push(colors[colorIdx % colorLen]); }); cells.push( [0, 1, 2].map(i => i + cellsIdx), [0, 2, 3].map(i => i + cellsIdx), ); colorIdx++; cellsIdx += 4; } // 立方体的六个面 quad(1, 0, 3, 2); quad(4, 5, 6, 7); quad(2, 3, 7, 6); quad(5, 4, 0, 1); quad(3, 0, 4, 7); quad(6, 5, 1, 2); return { positions, color, cells }; } const geometry = cube(1.0, [ [250/255, 128/255, 114/255, 1], // salmon rgb(250 128 114) [218/255, 165/255, 32/255, 1],// goldenrod rgb(218, 165, 32) [46/255, 139/255, 87/255, 1], // seagreen rgb(46 139 87) [255/255, 192/255, 203/255, 1], // pink rgb(255, 192, 203) [135/255, 206/255, 235/255, 1],// skyblue rgb(135, 206, 235) [106/255, 90/255, 205/255, 1], // slateblue rgb(106, 90, 205) ]); renderer.setMeshData([ { positions: geometry.positions, attributes: { color: geometry.color, }, cells: geometry.cells, }, ]); renderer.render(); </script> </body> </html>
投影矩阵:变换 WebGL 坐标系
上面朝向我们的面应该是 goldenrod 颜色, WebGL 默认的剪裁坐标的 z 轴方向,的确是朝内的。WebGL 坐标系就是一个左手系而不是右手系。下面我们需要将 WebGL 的坐标系从左手系转换为右手系。
实际上就是将 z 轴坐标方向反转,对应的齐次矩阵如下:
[ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, -1, 0, 0, 0, 0, 1 ]
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>投影矩阵:变换 WebGL 坐标系</title> <style> canvas { border: 1px dashed rgb(250, 128, 114); } </style> </head> <body> <canvas width="512" height="512"></canvas> <script src="./common/lib/gl-renderer.js"></script> <script type="module"> const vertex = ` attribute vec3 a_vertexPosition; attribute vec4 color; varying vec4 vColor; uniform mat4 projectionMatrix; void main() { gl_PointSize = 1.0; vColor = color; gl_Position = projectionMatrix * vec4(a_vertexPosition, 1); } `; const fragment = ` #ifdef GL_ES precision highp float; #endif varying vec4 vColor; void main() { gl_FragColor = vColor; } `; const canvas = document.querySelector("canvas"); // 开启深度检测 const renderer = new GlRenderer(canvas, { depth: true }); const program = renderer.compileSync(fragment, vertex); renderer.useProgram(program); // 用来生成立方体 6 个面的 24 个顶点,以及 12 个三角形的索引 function cube(size = 1.0, colors = [[1, 0, 0, 1]]) { const h = 0.5 * size; // 立方体的顶点 const vertices = [ [-h, -h, -h], [-h, h, -h], [h, h, -h], [h, -h, -h], [-h, -h, h], [-h, h, h], [h, h, h], [h, -h, h], ]; const positions = []; const color = []; const cells = []; let colorIdx = 0; let cellsIdx = 0; const colorLen = colors.length; function quad(a, b, c, d) { [a, b, c, d].forEach((i) => { positions.push(vertices[i]); color.push(colors[colorIdx % colorLen]); }); cells.push( [0, 1, 2].map(i => i + cellsIdx), [0, 2, 3].map(i => i + cellsIdx), ); colorIdx++; cellsIdx += 4; } // 立方体的六个面 quad(1, 0, 3, 2); quad(4, 5, 6, 7); quad(2, 3, 7, 6); quad(5, 4, 0, 1); quad(3, 0, 4, 7); quad(6, 5, 1, 2); return { positions, color, cells }; } const geometry = cube(1.0, [ [250/255, 128/255, 114/255, 1], // salmon rgb(250 128 114) [218/255, 165/255, 32/255, 1],// goldenrod rgb(218, 165, 32) [46/255, 139/255, 87/255, 1], // seagreen rgb(46 139 87) [255/255, 192/255, 203/255, 1], // pink rgb(255, 192, 203) [135/255, 206/255, 235/255, 1],// skyblue rgb(135, 206, 235) [106/255, 90/255, 205/255, 1], // slateblue rgb(106, 90, 205) ]); // 将 z 轴坐标方向反转,对应的齐次矩阵如下,转换坐标的齐次矩阵,又被称为投影矩阵(ProjectionMatrix) renderer.uniforms.projectionMatrix = [ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, -1, 0, 0, 0, 0, 1, ]; renderer.setMeshData([ { positions: geometry.positions, attributes: { color: geometry.color, }, cells: geometry.cells, }, ]); renderer.render(); </script> </body> </html>
模型矩阵:让立方体旋转起来
用立方体沿 x、y、z 轴的旋转来生成模型矩阵。以 x、y、z 三个方向的旋转得到三个齐次矩阵,然后将它们相乘,就能得到最终的模型矩阵。
如何用 WebGL 绘制圆柱体
圆柱体的两个底面都是圆,可以用割圆的方式对圆进行简单的三角剖分,然后把圆柱的侧面用上下两个圆上的顶点进行三角剖分。
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>如何用 WebGL 绘制圆柱体</title> <style> canvas { border: 1px dashed rgb(250, 128, 114); } </style> </head> <body> <canvas width="512" height="512"></canvas> <script src="./common/lib/gl-renderer.js"></script> <script type="module"> import { multiply } from './common/lib/math/functions/Mat4Func.js'; const vertex = ` attribute vec3 a_vertexPosition; attribute vec4 color; varying vec4 vColor; uniform mat4 projectionMatrix; uniform mat4 modelMatrix; void main() { gl_PointSize = 1.0; vColor = color; gl_Position = projectionMatrix * modelMatrix * vec4(a_vertexPosition, 1.0); } `; const fragment = ` #ifdef GL_ES precision highp float; #endif varying vec4 vColor; void main() { gl_FragColor = vColor; } `; const canvas = document.querySelector("canvas"); // 开启深度检测 const renderer = new GlRenderer(canvas, { depth: true }); const program = renderer.compileSync(fragment, vertex); renderer.useProgram(program); function cylinder(radius = 1.0, height = 1.0, segments = 30, colorCap = [0, 0, 1, 1], colorSide = [1, 0, 0, 1]) { const positions = []; const cells = []; const color = []; const cap = [[0, 0]]; const h = 0.5 * height; // 顶和底的圆 for(let i = 0; i <= segments; i++) { const theta = Math.PI * 2 * i / segments; const p = [radius * Math.cos(theta), radius * Math.sin(theta)]; cap.push(p); } positions.push(...cap.map(([x, y]) => [x, y, -h])); for(let i = 1; i < cap.length - 1; i++) { cells.push([0, i, i + 1]); } cells.push([0, cap.length - 1, 1]); let offset = positions.length; positions.push(...cap.map(([x, y]) => [x, y, h])); for(let i = 1; i < cap.length - 1; i++) { cells.push([offset, offset + i, offset + i + 1]); } cells.push([offset, offset + cap.length - 1, offset + 1]); color.push(...positions.map(() => colorCap)); // 侧面 offset = positions.length; for(let i = 1; i < cap.length; i++) { const a = [...cap[i], h]; const b = [...cap[i], -h]; const nextIdx = i < cap.length - 1 ? i + 1 : 1; const c = [...cap[nextIdx], -h]; const d = [...cap[nextIdx], h]; positions.push(a, b, c, d); color.push(colorSide, colorSide, colorSide, colorSide); cells.push([offset, offset + 1, offset + 2], [offset, offset + 2, offset + 3]); offset += 4; } return { positions, cells, color }; } const geometry = cylinder(0.2, 1.0, 400, [250/255, 128/255, 114/255, 1], // salmon rgb(250 128 114) [46/255, 139/255, 87/255, 1], // seagreen rgb(46 139 87) ); // 将 z 轴坐标方向反转,对应的齐次矩阵如下,转换坐标的齐次矩阵,又被称为投影矩阵(ProjectionMatrix) renderer.uniforms.projectionMatrix = [ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, -1, 0, 0, 0, 0, 1, ]; function fromRotation(rotationX, rotationY, rotationZ) { let c = Math.cos(rotationX); let s = Math.sin(rotationX); const rx = [ 1, 0, 0, 0, 0, c, s, 0, 0, -s, c, 0, 0, 0, 0, 1, ]; c = Math.cos(rotationY); s = Math.sin(rotationY); const ry = [ c, 0, s, 0, 0, 1, 0, 0, -s, 0, c, 0, 0, 0, 0, 1, ]; c = Math.cos(rotationZ); s = Math.sin(rotationZ); const rz = [ c, s, 0, 0, -s, c, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, ]; const ret = []; multiply(ret, rx, ry); multiply(ret, ret, rz); return ret; } renderer.setMeshData([ { positions: geometry.positions, attributes: { color: geometry.color, }, cells: geometry.cells, }, ]); let rotationX = 0; let rotationY = 0; let rotationZ = 0; function update() { rotationX += 0.003; rotationY += 0.005; rotationZ += 0.007; renderer.uniforms.modelMatrix = fromRotation(rotationX, rotationY, rotationZ); requestAnimationFrame(update); } update(); renderer.render(); </script> </body> </html>
使用法向量和法向量矩阵来实现点光源光照效果
对于圆柱体来说,底面和顶面法线分别是 (0, 0, -1) 和 (0, 0, 1),侧面的法向量可以通过三角网格来计算。
因为几何体是由三角网格构成的,而法线是垂直于三角网格的线,如果要计算法线,我们可以借助三角形的顶点,使用向量的叉积定理来求。假设在一个平面内,有向量 a 和 b,n 是它们的法向量,那我们可以得到公式:n = a X b。
在片元着色器中,拿到的是变换后的顶点坐标,需要对法向量也进行变换,可以通过一个矩阵来实现,这个矩阵叫做法向量矩阵(NormalMatrix)。
在顶点着色器中,计算位于(1,0,0)坐标处的点光源与几何体法线的夹角余弦。根据物体漫反射模型,光照强度等于光线与法向量夹角的余弦,就能在片元着色器叠加光照。
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>使用法向量和法向量矩阵来实现点光源光照效果</title> <style> canvas { border: 1px dashed rgb(250, 128, 114); } </style> </head> <body> <canvas width="512" height="512"></canvas> <script src="./common/lib/gl-renderer.js"></script> <script type="module"> import { multiply } from './common/lib/math/functions/Mat4Func.js'; import { cross, subtract, normalize } from './common/lib/math/functions/Vec3Func.js'; import { normalFromMat4 } from './common/lib/math/functions/Mat3Func.js'; const vertex = ` attribute vec3 a_vertexPosition; attribute vec4 color; attribute vec3 normal; varying vec4 vColor; varying float vCos; uniform mat4 projectionMatrix; uniform mat4 modelMatrix; uniform mat3 normalMatrix; const vec3 lightPosition = vec3(1, 0, 0); void main() { gl_PointSize = 1.0; vColor = color; vec4 pos = modelMatrix * vec4(a_vertexPosition, 1.0); vec3 invLight = lightPosition - pos.xyz; vec3 norm = normalize(normalMatrix * normal); vCos = max(dot(normalize(invLight), norm), 0.0); gl_Position = projectionMatrix * pos; } `; const fragment = ` #ifdef GL_ES precision highp float; #endif uniform vec4 lightColor; varying vec4 vColor; varying float vCos; void main() { gl_FragColor.rgb = vColor.rgb + vCos * lightColor.a * lightColor.rgb; gl_FragColor.a = vColor.a; } `; const canvas = document.querySelector("canvas"); // 开启深度检测 const renderer = new GlRenderer(canvas, { depth: true }); const program = renderer.compileSync(fragment, vertex); renderer.useProgram(program); function cylinder(radius = 1.0, height = 1.0, segments = 30, colorCap = [0, 0, 1, 1], colorSide = [1, 0, 0, 1]) { const positions = []; const cells = []; const color = []; const cap = [[0, 0]]; const h = 0.5 * height; const normal = []; // 顶和底的圆 for(let i = 0; i <= segments; i++) { const theta = Math.PI * 2 * i / segments; const p = [radius * Math.cos(theta), radius * Math.sin(theta)]; cap.push(p); } positions.push(...cap.map(([x, y]) => [x, y, -h])); normal.push(...cap.map(() => [0, 0, -1])); for(let i = 1; i < cap.length - 1; i++) { cells.push([0, i, i + 1]); } cells.push([0, cap.length - 1, 1]); let offset = positions.length; positions.push(...cap.map(([x, y]) => [x, y, h])); normal.push(...cap.map(() => [0, 0, 1])); for(let i = 1; i < cap.length - 1; i++) { cells.push([offset, offset + i, offset + i + 1]); } cells.push([offset, offset + cap.length - 1, offset + 1]); color.push(...positions.map(() => colorCap)); const tmp1 = []; const tmp2 = []; // 侧面,这里需要求出侧面的法向量 offset = positions.length; for(let i = 1; i < cap.length; i++) { const a = [...cap[i], h]; const b = [...cap[i], -h]; const nextIdx = i < cap.length - 1 ? i + 1 : 1; const c = [...cap[nextIdx], -h]; const d = [...cap[nextIdx], h]; positions.push(a, b, c, d); const norm = []; cross(norm, subtract(tmp1, b, a), subtract(tmp2, c, a)); normalize(norm, norm); normal.push(norm, norm, norm, norm); // abcd四个点共面,它们的法向量相同 color.push(colorSide, colorSide, colorSide, colorSide); cells.push([offset, offset + 1, offset + 2], [offset, offset + 2, offset + 3]); offset += 4; } return { positions, cells, color, normal }; } const geometry = cylinder(0.2, 1.0, 400, [250/255, 128/255, 114/255, 1], // salmon rgb(250 128 114) [46/255, 139/255, 87/255, 1], // seagreen rgb(46 139 87) ); // 将 z 轴坐标方向反转,对应的齐次矩阵如下,转换坐标的齐次矩阵,又被称为投影矩阵(ProjectionMatrix) renderer.uniforms.projectionMatrix = [ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, -1, 0, 0, 0, 0, 1, ]; renderer.uniforms.lightColor = [218/255, 165/255, 32/255, 0.6];// goldenrod rgb(218, 165, 32) function fromRotation(rotationX, rotationY, rotationZ) { let c = Math.cos(rotationX); let s = Math.sin(rotationX); const rx = [ 1, 0, 0, 0, 0, c, s, 0, 0, -s, c, 0, 0, 0, 0, 1, ]; c = Math.cos(rotationY); s = Math.sin(rotationY); const ry = [ c, 0, s, 0, 0, 1, 0, 0, -s, 0, c, 0, 0, 0, 0, 1, ]; c = Math.cos(rotationZ); s = Math.sin(rotationZ); const rz = [ c, s, 0, 0, -s, c, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, ]; const ret = []; multiply(ret, rx, ry); multiply(ret, ret, rz); return ret; } console.log(geometry); renderer.setMeshData([ { positions: geometry.positions, attributes: { color: geometry.color, normal: geometry.normal }, cells: geometry.cells, }, ]); let rotationX = 0; let rotationY = 0; let rotationZ = 0; function update() { rotationX += 0.003; rotationY += 0.005; rotationZ += 0.007; const modelMatrix = fromRotation(rotationX, rotationY, rotationZ); renderer.uniforms.modelMatrix = modelMatrix; renderer.uniforms.normalMatrix = normalFromMat4([], modelMatrix); requestAnimationFrame(update); } update(); renderer.render(); </script> </body> </html>