第 8 篇:更广阔的世界 - 加载 3D 模型
在前面的教程中,我们学会了如何手动定义顶点数据来创建简单的几何体,比如三角形和立方体。但是,如果我们想要渲染更复杂的模型——比如一个人物角色、一辆汽车或者一个精细的建筑——手动编写成千上万个顶点数据显然是不现实的。
这就是为什么我们需要学习如何加载外部 3D 模型文件。在这篇教程中,我们将探索如何在 WebGL 中加载和渲染 .OBJ
格式的 3D 模型,让我们的应用能够展示专业 3D 建模软件创建的复杂模型。
为什么选择 OBJ 格式?
在众多 3D 模型格式中(如 FBX、GLTF、Collada 等),我们选择 OBJ 格式作为入门的原因有:
- 简单易懂: OBJ 是纯文本格式,可以用任何文本编辑器打开查看
- 广泛支持: 几乎所有 3D 建模软件都支持导出 OBJ 格式
- 无需额外库: 解析逻辑相对简单,适合学习底层原理
- 社区资源丰富: 网上有大量免费的 OBJ 模型可供下载
注意: 虽然 OBJ 格式适合学习,但在生产环境中,GLTF 格式因其对动画、材质等特性的更好支持而更受欢迎。
OBJ 文件格式解析
让我们先了解 OBJ 文件的基本结构。下面是一个简单的 OBJ 文件示例:
# 这是注释
# 顶点坐标 (x, y, z)
v 0.0 0.0 0.0
v 0.0 1.0 0.0
v 1.0 0.0 0.0
v 1.0 1.0 0.0# 纹理坐标 (u, v)
vt 0.0 0.0
vt 0.0 1.0
vt 1.0 0.0
vt 1.0 1.0# 顶点法向量 (nx, ny, nz)
vn 0.0 0.0 1.0
vn 0.0 0.0 1.0
vn 0.0 0.0 1.0
vn 0.0 0.0 1.0# 面定义 (顶点索引/纹理索引/法向量索引)
f 1/1/1 2/2/2 3/3/3
f 2/2/2 4/4/4 3/3/3
OBJ 格式的主要元素:
- v (vertex): 定义 3D 空间中的顶点坐标
- vt (texture coordinate): 定义纹理坐标(UV 映射)
- vn (vertex normal): 定义顶点的法向量(用于光照计算)
- f (face): 定义面(通常是三角形),使用索引引用前面定义的数据
重要: OBJ 文件中的索引是从 1 开始的,而 JavaScript 数组索引是从 0 开始的,解析时需要注意转换。
实现 OBJ 解析器
现在让我们编写一个简单的 OBJ 文件解析器:
class OBJParser {/*** 解析 OBJ 文件内容* @param {string} objText - OBJ 文件的文本内容* @returns {Object} 包含顶点、纹理坐标、法向量和索引的对象*/static parse(objText) {// 临时数组,存储解析出的原始数据const tempPositions = [];const tempTexCoords = [];const tempNormals = [];// 最终的顶点数据(展开后)const positions = [];const texCoords = [];const normals = [];const indices = [];// 用于去重的映射表const vertexMap = new Map();let currentIndex = 0;// 按行分割文本const lines = objText.split('\n');for (let line of lines) {line = line.trim();// 跳过空行和注释if (!line || line.startsWith('#')) continue;const parts = line.split(/\s+/);const type = parts[0];switch (type) {case 'v':// 解析顶点坐标tempPositions.push([parseFloat(parts[1]),parseFloat(parts[2]),parseFloat(parts[3])]);break;case 'vt':// 解析纹理坐标tempTexCoords.push([parseFloat(parts[1]),parseFloat(parts[2])]);break;case 'vn':// 解析法向量tempNormals.push([parseFloat(parts[1]),parseFloat(parts[2]),parseFloat(parts[3])]);break;case 'f':// 解析面(三角形)// OBJ 可能有四边形,我们需要三角化const faceVertices = parts.slice(1);// 将多边形分解为三角形扇形for (let i = 1; i < faceVertices.length - 1; i++) {const triangleVertices = [faceVertices[0],faceVertices[i],faceVertices[i + 1]];// 处理三角形的每个顶点for (const vertex of triangleVertices) {const index = this.processVertex(vertex,tempPositions,tempTexCoords,tempNormals,vertexMap,positions,texCoords,normals);indices.push(index);}}break;}}return {positions: new Float32Array(positions),texCoords: new Float32Array(texCoords),normals: new Float32Array(normals),indices: new Uint16Array(indices)};}/*** 处理单个顶点* @returns {number} 顶点在最终数组中的索引*/static processVertex(vertexString,tempPositions,tempTexCoords,tempNormals,vertexMap,positions,texCoords,normals) {// 检查是否已经处理过这个顶点组合if (vertexMap.has(vertexString)) {return vertexMap.get(vertexString);}// 解析顶点字符串: "position/texcoord/normal"const [posIdx, texIdx, normIdx] = vertexString.split('/').map(s => s ? parseInt(s) - 1 : -1); // OBJ 索引从 1 开始// 添加位置数据if (posIdx >= 0 && posIdx < tempPositions.length) {positions.push(...tempPositions[posIdx]);} else {positions.push(0, 0, 0); // 默认值}// 添加纹理坐标if (texIdx >= 0 && texIdx < tempTexCoords.length) {texCoords.push(...tempTexCoords[texIdx]);} else {texCoords.push(0, 0); // 默认值}// 添加法向量if (normIdx >= 0 && normIdx < tempNormals.length) {normals.push(...tempNormals[normIdx]);} else {normals.push(0, 0, 1); // 默认法向量}// 记录这个顶点的索引const index = vertexMap.size;vertexMap.set(vertexString, index);return index;}
}
解析器的关键点:
- 顶点去重: 使用
Map
来追踪已处理的顶点组合,避免重复数据 - 索引转换: 将 OBJ 的 1-based 索引转换为 JavaScript 的 0-based 索引
- 三角化: 将可能的四边形或多边形面分解为三角形
- 数据展开: 根据面的引用,将顶点属性组合成 WebGL 可用的格式
加载和解析 OBJ 文件
现在让我们编写一个函数来加载 OBJ 文件:
/*** 从 URL 加载 OBJ 模型* @param {string} url - OBJ 文件的 URL* @returns {Promise<Object>} 解析后的模型数据*/
async function loadOBJ(url) {try {const response = await fetch(url);if (!response.ok) {throw new Error(`HTTP error! status: ${response.status}`);}const objText = await response.text();const modelData = OBJParser.parse(objText);console.log(`模型加载成功: ${modelData.positions.length / 3} 个顶点`);return modelData;} catch (error) {console.error('加载 OBJ 文件失败:', error);throw error;}
}
将模型数据传递给 WebGL
加载了模型数据后,我们需要将其传递给 WebGL。这个过程与我们之前手动创建几何体的过程相同:
/*** 创建模型的 WebGL 缓冲区*/
function createModelBuffers(gl, modelData) {// 创建位置缓冲区const positionBuffer = gl.createBuffer();gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);gl.bufferData(gl.ARRAY_BUFFER, modelData.positions, gl.STATIC_DRAW);// 创建纹理坐标缓冲区const texCoordBuffer = gl.createBuffer();gl.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer);gl.bufferData(gl.ARRAY_BUFFER, modelData.texCoords, gl.STATIC_DRAW);// 创建法向量缓冲区const normalBuffer = gl.createBuffer();gl.bindBuffer(gl.ARRAY_BUFFER, normalBuffer);gl.bufferData(gl.ARRAY_BUFFER, modelData.normals, gl.STATIC_DRAW);// 创建索引缓冲区const indexBuffer = gl.createBuffer();gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, modelData.indices, gl.STATIC_DRAW);return {position: positionBuffer,texCoord: texCoordBuffer,normal: normalBuffer,index: indexBuffer,vertexCount: modelData.indices.length};
}
渲染加载的模型
现在让我们把所有部分整合起来,创建一个完整的渲染循环:
/*** 设置顶点属性*/
function setupVertexAttributes(gl, programInfo, buffers) {// 位置属性gl.bindBuffer(gl.ARRAY_BUFFER, buffers.position);gl.vertexAttribPointer(programInfo.attribLocations.position,3, // 每个顶点 3 个分量 (x, y, z)gl.FLOAT,false,0,0);gl.enableVertexAttribArray(programInfo.attribLocations.position);// 纹理坐标属性gl.bindBuffer(gl.ARRAY_BUFFER, buffers.texCoord);gl.vertexAttribPointer(programInfo.attribLocations.texCoord,2, // 每个顶点 2 个分量 (u, v)gl.FLOAT,false,0,0);gl.enableVertexAttribArray(programInfo.attribLocations.texCoord);// 法向量属性gl.bindBuffer(gl.ARRAY_BUFFER, buffers.normal);gl.vertexAttribPointer(programInfo.attribLocations.normal,3, // 每个顶点 3 个分量 (nx, ny, nz)gl.FLOAT,false,0,0);gl.enableVertexAttribArray(programInfo.attribLocations.normal);// 绑定索引缓冲区gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, buffers.index);
}/*** 渲染场景*/
function render(gl, programInfo, buffers, uniforms) {// 清除画布gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);// 使用着色器程序gl.useProgram(programInfo.program);// 设置顶点属性setupVertexAttributes(gl, programInfo, buffers);// 设置 uniform 变量gl.uniformMatrix4fv(programInfo.uniformLocations.modelMatrix,false,uniforms.modelMatrix);gl.uniformMatrix4fv(programInfo.uniformLocations.viewMatrix,false,uniforms.viewMatrix);gl.uniformMatrix4fv(programInfo.uniformLocations.projectionMatrix,false,uniforms.projectionMatrix);// 绘制模型gl.drawElements(gl.TRIANGLES,buffers.vertexCount,gl.UNSIGNED_SHORT,0);
}
完整示例
让我们把所有内容整合到一个完整的 HTML 示例中:
<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><title>WebGL - 加载 OBJ 模型</title><style>body {margin: 0;padding: 0;background: #1a1a1a;display: flex;justify-content: center;align-items: center;height: 100vh;font-family: Arial, sans-serif;}canvas {border: 2px solid #333;box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);}#info {position: absolute;top: 20px;left: 20px;color: white;background: rgba(0, 0, 0, 0.7);padding: 15px;border-radius: 5px;font-size: 14px;}#loading {position: absolute;color: white;font-size: 20px;}</style>
</head>
<body><canvas id="glCanvas" width="800" height="600"></canvas><div id="info"><div>拖拽鼠标旋转模型</div><div id="stats"></div></div><div id="loading">加载中...</div><script>// ==================== 着色器代码 ====================const vertexShaderSource = `attribute vec3 aPosition;attribute vec2 aTexCoord;attribute vec3 aNormal;uniform mat4 uModelMatrix;uniform mat4 uViewMatrix;uniform mat4 uProjectionMatrix;uniform mat4 uNormalMatrix;varying vec2 vTexCoord;varying vec3 vNormal;varying vec3 vFragPos;void main() {vec4 worldPos = uModelMatrix * vec4(aPosition, 1.0);vFragPos = worldPos.xyz;gl_Position = uProjectionMatrix * uViewMatrix * worldPos;vTexCoord = aTexCoord;vNormal = mat3(uNormalMatrix) * aNormal;}`;const fragmentShaderSource = `precision mediump float;varying vec2 vTexCoord;varying vec3 vNormal;varying vec3 vFragPos;uniform vec3 uLightPos;uniform vec3 uViewPos;uniform vec3 uLightColor;uniform vec3 uObjectColor;void main() {// 环境光float ambientStrength = 0.3;vec3 ambient = ambientStrength * uLightColor;// 漫反射vec3 norm = normalize(vNormal);vec3 lightDir = normalize(uLightPos - vFragPos);float diff = max(dot(norm, lightDir), 0.0);vec3 diffuse = diff * uLightColor;// 镜面光float specularStrength = 0.5;vec3 viewDir = normalize(uViewPos - vFragPos);vec3 reflectDir = reflect(-lightDir, norm);float spec = pow(max(dot(viewDir, reflectDir), 0.0), 32.0);vec3 specular = specularStrength * spec * uLightColor;// 最终颜色vec3 result = (ambient + diffuse + specular) * uObjectColor;gl_FragColor = vec4(result, 1.0);}`;// ==================== OBJ 解析器 ====================class OBJParser {static parse(objText) {const tempPositions = [];const tempTexCoords = [];const tempNormals = [];const positions = [];const texCoords = [];const normals = [];const indices = [];const vertexMap = new Map();const lines = objText.split('\n');for (let line of lines) {line = line.trim();if (!line || line.startsWith('#')) continue;const parts = line.split(/\s+/);const type = parts[0];switch (type) {case 'v':tempPositions.push([parseFloat(parts[1]),parseFloat(parts[2]),parseFloat(parts[3])]);break;case 'vt':tempTexCoords.push([parseFloat(parts[1]),parseFloat(parts[2])]);break;case 'vn':tempNormals.push([parseFloat(parts[1]),parseFloat(parts[2]),parseFloat(parts[3])]);break;case 'f':const faceVertices = parts.slice(1);for (let i = 1; i < faceVertices.length - 1; i++) {const triangleVertices = [faceVertices[0],faceVertices[i],faceVertices[i + 1]];for (const vertex of triangleVertices) {const index = this.processVertex(vertex,tempPositions,tempTexCoords,tempNormals,vertexMap,positions,texCoords,normals);indices.push(index);}}break;}}return {positions: new Float32Array(positions),texCoords: new Float32Array(texCoords),normals: new Float32Array(normals),indices: new Uint16Array(indices)};}static processVertex(vertexString,tempPositions,tempTexCoords,tempNormals,vertexMap,positions,texCoords,normals) {if (vertexMap.has(vertexString)) {return vertexMap.get(vertexString);}const [posIdx, texIdx, normIdx] = vertexString.split('/').map(s => s ? parseInt(s) - 1 : -1);if (posIdx >= 0 && posIdx < tempPositions.length) {positions.push(...tempPositions[posIdx]);} else {positions.push(0, 0, 0);}if (texIdx >= 0 && texIdx < tempTexCoords.length) {texCoords.push(...tempTexCoords[texIdx]);} else {texCoords.push(0, 0);}if (normIdx >= 0 && normIdx < tempNormals.length) {normals.push(...tempNormals[normIdx]);} else {normals.push(0, 0, 1);}const index = vertexMap.size;vertexMap.set(vertexString, index);return index;}}// ==================== 矩阵工具函数 ====================const mat4 = {create() {return new Float32Array([1, 0, 0, 0,0, 1, 0, 0,0, 0, 1, 0,0, 0, 0, 1]);},perspective(fov, aspect, near, far) {const f = 1.0 / Math.tan(fov / 2);const nf = 1 / (near - far);return new Float32Array([f / aspect, 0, 0, 0,0, f, 0, 0,0, 0, (far + near) * nf, -1,0, 0, 2 * far * near * nf, 0]);},lookAt(eye, center, up) {const z = [eye[0] - center[0],eye[1] - center[1],eye[2] - center[2]];const len = Math.sqrt(z[0] * z[0] + z[1] * z[1] + z[2] * z[2]);z[0] /= len; z[1] /= len; z[2] /= len;const x = [up[1] * z[2] - up[2] * z[1],up[2] * z[0] - up[0] * z[2],up[0] * z[1] - up[1] * z[0]];const lenX = Math.sqrt(x[0] * x[0] + x[1] * x[1] + x[2] * x[2]);x[0] /= lenX; x[1] /= lenX; x[2] /= lenX;const y = [z[1] * x[2] - z[2] * x[1],z[2] * x[0] - z[0] * x[2],z[0] * x[1] - z[1] * x[0]];return new Float32Array([x[0], y[0], z[0], 0,x[1], y[1], z[1], 0,x[2], y[2], z[2], 0,-(x[0] * eye[0] + x[1] * eye[1] + x[2] * eye[2]),-(y[0] * eye[0] + y[1] * eye[1] + y[2] * eye[2]),-(z[0] * eye[0] + z[1] * eye[1] + z[2] * eye[2]),1]);},rotateY(angle) {const c = Math.cos(angle);const s = Math.sin(angle);return new Float32Array([c, 0, s, 0,0, 1, 0, 0,-s, 0, c, 0,0, 0, 0, 1]);},rotateX(angle) {const c = Math.cos(angle);const s = Math.sin(angle);return new Float32Array([1, 0, 0, 0,0, c, -s, 0,0, s, c, 0,0, 0, 0, 1]);},multiply(a, b) {const result = new Float32Array(16);for (let i = 0; i < 4; i++) {for (let j = 0; j < 4; j++) {result[i * 4 + j] =a[i * 4 + 0] * b[0 * 4 + j] +a[i * 4 + 1] * b[1 * 4 + j] +a[i * 4 + 2] * b[2 * 4 + j] +a[i * 4 + 3] * b[3 * 4 + j];}}return result;},invert(m) {const inv = new Float32Array(16);inv[0] = m[5] * m[10] * m[15] - m[5] * m[11] * m[14] -m[9] * m[6] * m[15] + m[9] * m[7] * m[14] +m[13] * m[6] * m[11] - m[13] * m[7] * m[10];inv[4] = -m[4] * m[10] * m[15] + m[4] * m[11] * m[14] +m[8] * m[6] * m[15] - m[8] * m[7] * m[14] -m[12] * m[6] * m[11] + m[12] * m[7] * m[10];inv[8] = m[4] * m[9] * m[15] - m[4] * m[11] * m[13] -m[8] * m[5] * m[15] + m[8] * m[7] * m[13] +m[12] * m[5] * m[11] - m[12] * m[7] * m[9];inv[12] = -m[4] * m[9] * m[14] + m[4] * m[10] * m[13] +m[8] * m[5] * m[14] - m[8] * m[6] * m[13] -m[12] * m[5] * m[10] + m[12] * m[6] * m[9];inv[1] = -m[1] * m[10] * m[15] + m[1] * m[11] * m[14] +m[9] * m[2] * m[15] - m[9] * m[3] * m[14] -m[13] * m[2] * m[11] + m[13] * m[3] * m[10];inv[5] = m[0] * m[10] * m[15] - m[0] * m[11] * m[14] -m[8] * m[2] * m[15] + m[8] * m[3] * m[14] +m[12] * m[2] * m[11] - m[12] * m[3] * m[10];inv[9] = -m[0] * m[9] * m[15] + m[0] * m[11] * m[13] +m[8] * m[1] * m[15] - m[8] * m[3] * m[13] -m[12] * m[1] * m[11] + m[12] * m[3] * m[9];inv[13] = m[0] * m[9] * m[14] - m[0] * m[10] * m[13] -m[8] * m[1] * m[14] + m[8] * m[2] * m[13] +m[12] * m[1] * m[10] - m[12] * m[2] * m[9];inv[2] = m[1] * m[6] * m[15] - m[1] * m[7] * m[14] -m[5] * m[2] * m[15] + m[5] * m[3] * m[14] +m[13] * m[2] * m[7] - m[13] * m[3] * m[6];inv[6] = -m[0] * m[6] * m[15] + m[0] * m[7] * m[14] +m[4] * m[2] * m[15] - m[4] * m[3] * m[14] -m[12] * m[2] * m[7] + m[12] * m[3] * m[6];inv[10] = m[0] * m[5] * m[15] - m[0] * m[7] * m[13] -m[4] * m[1] * m[15] + m[4] * m[3] * m[13] +m[12] * m[1] * m[7] - m[12] * m[3] * m[5];inv[14] = -m[0] * m[5] * m[14] + m[0] * m[6] * m[13] +m[4] * m[1] * m[14] - m[4] * m[2] * m[13] -m[12] * m[1] * m[6] + m[12] * m[2] * m[5];inv[3] = -m[1] * m[6] * m[11] + m[1] * m[7] * m[10] +m[5] * m[2] * m[11] - m[5] * m[3] * m[10] -m[9] * m[2] * m[7] + m[9] * m[3] * m[6];inv[7] = m[0] * m[6] * m[11] - m[0] * m[7] * m[10] -m[4] * m[2] * m[11] + m[4] * m[3] * m[10] +m[8] * m[2] * m[7] - m[8] * m[3] * m[6];inv[11] = -m[0] * m[5] * m[11] + m[0] * m[7] * m[9] +m[4] * m[1] * m[11] - m[4] * m[3] * m[9] -m[8] * m[1] * m[7] + m[8] * m[3] * m[5];inv[15] = m[0] * m[5] * m[10] - m[0] * m[6] * m[9] -m[4] * m[1] * m[10] + m[4] * m[2] * m[9] +m[8] * m[1] * m[6] - m[8] * m[2] * m[5];let det = m[0] * inv[0] + m[1] * inv[4] + m[2] * inv[8] + m[3] * inv[12];if (det === 0) return null;det = 1.0 / det;for (let i = 0; i < 16; i++) {inv[i] = inv[i] * det;}return inv;},transpose(m) {return new Float32Array([m[0], m[4], m[8], m[12],m[1], m[5], m[9], m[13],m[2], m[6], m[10], m[14],m[3], m[7], m[11], m[15]]);}};// ==================== WebGL 初始化 ====================function initShaders(gl) {const vertexShader = gl.createShader(gl.VERTEX_SHADER);gl.shaderSource(vertexShader, vertexShaderSource);gl.compileShader(vertexShader);if (!gl.getShaderParameter(vertexShader, gl.COMPILE_STATUS)) {console.error('顶点着色器编译失败:', gl.getShaderInfoLog(vertexShader));return null;}const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);gl.shaderSource(fragmentShader, fragmentShaderSource);gl.compileShader(fragmentShader);if (!gl.getShaderParameter(fragmentShader, gl.COMPILE_STATUS)) {console.error('片段着色器编译失败:', gl.getShaderInfoLog(fragmentShader));return null;}const program = gl.createProgram();gl.attachShader(program, vertexShader);gl.attachShader(program, fragmentShader);gl.linkProgram(program);if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {console.error('着色器程序链接失败:', gl.getProgramInfoLog(program));return null;}return {program: program,attribLocations: {position: gl.getAttribLocation(program, 'aPosition'),texCoord: gl.getAttribLocation(program, 'aTexCoord'),normal: gl.getAttribLocation(program, 'aNormal')},uniformLocations: {modelMatrix: gl.getUniformLocation(program, 'uModelMatrix'),viewMatrix: gl.getUniformLocation(program, 'uViewMatrix'),projectionMatrix: gl.getUniformLocation(program, 'uProjectionMatrix'),normalMatrix: gl.getUniformLocation(program, 'uNormalMatrix'),lightPos: gl.getUniformLocation(program, 'uLightPos'),viewPos: gl.getUniformLocation(program, 'uViewPos'),lightColor: gl.getUniformLocation(program, 'uLightColor'),objectColor: gl.getUniformLocation(program, 'uObjectColor')}};}function createModelBuffers(gl, modelData) {const positionBuffer = gl.createBuffer();gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);gl.bufferData(gl.ARRAY_BUFFER, modelData.positions, gl.STATIC_DRAW);const texCoordBuffer = gl.createBuffer();gl.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer);gl.bufferData(gl.ARRAY_BUFFER, modelData.texCoords, gl.STATIC_DRAW);const normalBuffer = gl.createBuffer();gl.bindBuffer(gl.ARRAY_BUFFER, normalBuffer);gl.bufferData(gl.ARRAY_BUFFER, modelData.normals, gl.STATIC_DRAW);const indexBuffer = gl.createBuffer();gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, modelData.indices, gl.STATIC_DRAW);return {position: positionBuffer,texCoord: texCoordBuffer,normal: normalBuffer,index: indexBuffer,vertexCount: modelData.indices.length};}// ==================== 主程序 ====================async function main() {const canvas = document.getElementById('glCanvas');const gl = canvas.getContext('webgl');if (!gl) {alert('无法初始化 WebGL,您的浏览器可能不支持。');return;}// 启用深度测试gl.enable(gl.DEPTH_TEST);gl.depthFunc(gl.LEQUAL);// 设置清除颜色gl.clearColor(0.1, 0.1, 0.1, 1.0);// 初始化着色器const programInfo = initShaders(gl);if (!programInfo) return;// 创建一个简单的立方体 OBJ 数据(用于演示)const cubeOBJ = `
# 立方体
v -1.0 -1.0 1.0
v 1.0 -1.0 1.0
v 1.0 1.0 1.0
v -1.0 1.0 1.0
v -1.0 -1.0 -1.0
v 1.0 -1.0 -1.0
v 1.0 1.0 -1.0
v -1.0 1.0 -1.0vt 0.0 0.0
vt 1.0 0.0
vt 1.0 1.0
vt 0.0 1.0vn 0.0 0.0 1.0
vn 0.0 0.0 -1.0
vn 0.0 1.0 0.0
vn 0.0 -1.0 0.0
vn 1.0 0.0 0.0
vn -1.0 0.0 0.0# 前面
f 1/1/1 2/2/1 3/3/1
f 1/1/1 3/3/1 4/4/1# 后面
f 5/1/2 6/2/2 7/3/2
f 5/1/2 7/3/2 8/4/2# 顶面
f 4/1/3 3/2/3 7/3/3
f 4/1/3 7/3/3 8/4/3# 底面
f 1/1/4 2/2/4 6/3/4
f 1/1/4 6/3/4 5/4/4# 右面
f 2/1/5 6/2/5 7/3/5
f 2/1/5 7/3/5 3/4/5# 左面
f 1/1/6 5/2/6 8/3/6
f 1/1/6 8/3/6 4/4/6`;// 解析模型const modelData = OBJParser.parse(cubeOBJ);const buffers = createModelBuffers(gl, modelData);// 隐藏加载提示document.getElementById('loading').style.display = 'none';// 显示统计信息document.getElementById('stats').innerHTML =`顶点数: ${modelData.positions.length / 3}<br>三角形数: ${modelData.indices.length / 3}`;// 设置投影矩阵const projectionMatrix = mat4.perspective(Math.PI / 4, // 45度视场角canvas.width / canvas.height,0.1,100.0);// 相机位置let cameraDistance = 5.0;const cameraPos = [0, 0, cameraDistance];// 旋转角度let rotationX = 0.2;let rotationY = 0;// 鼠标交互let isDragging = false;let lastX = 0;let lastY = 0;canvas.addEventListener('mousedown', (e) => {isDragging = true;lastX = e.clientX;lastY = e.clientY;});canvas.addEventListener('mousemove', (e) => {if (!isDragging) return;const deltaX = e.clientX - lastX;const deltaY = e.clientY - lastY;rotationY += deltaX * 0.01;rotationX += deltaY * 0.01;// 限制 X 轴旋转角度rotationX = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, rotationX));lastX = e.clientX;lastY = e.clientY;});canvas.addEventListener('mouseup', () => {isDragging = false;});canvas.addEventListener('mouseleave', () => {isDragging = false;});// 滚轮缩放canvas.addEventListener('wheel', (e) => {e.preventDefault();cameraDistance += e.deltaY * 0.01;cameraDistance = Math.max(2, Math.min(20, cameraDistance));cameraPos[2] = cameraDistance;});// 渲染循环function render() {// 清除画布gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);// 使用着色器程序gl.useProgram(programInfo.program);// 设置顶点属性gl.bindBuffer(gl.ARRAY_BUFFER, buffers.position);gl.vertexAttribPointer(programInfo.attribLocations.position, 3, gl.FLOAT, false, 0, 0);gl.enableVertexAttribArray(programInfo.attribLocations.position);gl.bindBuffer(gl.ARRAY_BUFFER, buffers.texCoord);gl.vertexAttribPointer(programInfo.attribLocations.texCoord, 2, gl.FLOAT, false, 0, 0);gl.enableVertexAttribArray(programInfo.attribLocations.texCoord);gl.bindBuffer(gl.ARRAY_BUFFER, buffers.normal);gl.vertexAttribPointer(programInfo.attribLocations.normal, 3, gl.FLOAT, false, 0, 0);gl.enableVertexAttribArray(programInfo.attribLocations.normal);gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, buffers.index);// 计算模型矩阵const rotY = mat4.rotateY(rotationY);const rotX = mat4.rotateX(rotationX);const modelMatrix = mat4.multiply(rotY, rotX);// 计算视图矩阵const viewMatrix = mat4.lookAt([0, 0, cameraDistance],[0, 0, 0],[0, 1, 0]);// 计算法向量矩阵(模型矩阵的逆转置)const normalMatrix = mat4.transpose(mat4.invert(modelMatrix));// 设置 uniform 变量gl.uniformMatrix4fv(programInfo.uniformLocations.modelMatrix, false, modelMatrix);gl.uniformMatrix4fv(programInfo.uniformLocations.viewMatrix, false, viewMatrix);gl.uniformMatrix4fv(programInfo.uniformLocations.projectionMatrix, false, projectionMatrix);gl.uniformMatrix4fv(programInfo.uniformLocations.normalMatrix, false, normalMatrix);gl.uniform3f(programInfo.uniformLocations.lightPos, 5.0, 5.0, 5.0);gl.uniform3f(programInfo.uniformLocations.viewPos, cameraPos[0], cameraPos[1], cameraPos[2]);gl.uniform3f(programInfo.uniformLocations.lightColor, 1.0, 1.0, 1.0);gl.uniform3f(programInfo.uniformLocations.objectColor, 0.3, 0.6, 0.9);// 绘制模型gl.drawElements(gl.TRIANGLES, buffers.vertexCount, gl.UNSIGNED_SHORT, 0);requestAnimationFrame(render);}render();}main();</script>
</body>
</html>
优化建议
1. 法向量计算
如果 OBJ 文件没有提供法向量数据,我们需要自己计算:
/*** 计算平面法向量*/
function calculateFaceNormal(v0, v1, v2) {// 计算两条边向量const edge1 = [v1[0] - v0[0],v1[1] - v0[1],v1[2] - v0[2]];const edge2 = [v2[0] - v0[0],v2[1] - v0[1],v2[2] - v0[2]];// 计算叉积(法向量)const normal = [edge1[1] * edge2[2] - edge1[2] * edge2[1],edge1[2] * edge2[0] - edge1[0] * edge2[2],edge1[0] * edge2[1] - edge1[1] * edge2[0]];// 归一化const length = Math.sqrt(normal[0] * normal[0] +normal[1] * normal[1] +normal[2] * normal[2]);return [normal[0] / length,normal[1] / length,normal[2] / length];
}
2. 性能优化
对于大型模型,可以考虑以下优化:
- Web Workers: 在后台线程中解析 OBJ 文件
- 增量加载: 对于超大模型,分批加载和渲染
- LOD (Level of Detail): 根据距离使用不同精度的模型
- 剔除 (Culling): 不渲染视野外的对象
// 使用 Web Worker 解析 OBJ
const worker = new Worker('obj-parser-worker.js');worker.postMessage({ objText: objFileContent });worker.onmessage = (e) => {const modelData = e.data;// 创建缓冲区并渲染...
};
3. 材质支持
完整的 OBJ 加载器还应该支持 MTL (Material Library) 文件:
// 解析 MTL 文件
class MTLParser {static parse(mtlText) {const materials = {};let currentMaterial = null;const lines = mtlText.split('\n');for (let line of lines) {line = line.trim();if (!line || line.startsWith('#')) continue;const parts = line.split(/\s+/);switch (parts[0]) {case 'newmtl':currentMaterial = parts[1];materials[currentMaterial] = {};break;case 'Ka': // 环境光颜色materials[currentMaterial].ambient = [parseFloat(parts[1]),parseFloat(parts[2]),parseFloat(parts[3])];break;case 'Kd': // 漫反射颜色materials[currentMaterial].diffuse = [parseFloat(parts[1]),parseFloat(parts[2]),parseFloat(parts[3])];break;case 'Ks': // 镜面光颜色materials[currentMaterial].specular = [parseFloat(parts[1]),parseFloat(parts[2]),parseFloat(parts[3])];break;case 'map_Kd': // 漫反射纹理贴图materials[currentMaterial].diffuseMap = parts[1];break;}}return materials;}
}
获取免费 3D 模型资源
学习过程中,你可以从以下网站获取免费的 OBJ 模型:
- Sketchfab (https://sketchfab.com) - 可以下载很多免费模型
- Free3D (https://free3d.com) - 大量免费 3D 模型
- TurboSquid (https://www.turbosquid.com/Search/3D-Models/free) - 有免费模型区域
- CGTrader (https://www.cgtrader.com/free-3d-models) - 免费 3D 模型市场
常见问题
1. 模型显示不完整或倒置?
这可能是坐标系的问题。不同的 3D 软件使用不同的坐标系统(Y-up vs Z-up)。你可能需要在加载后对模型进行变换:
// 如果模型是 Z-up,而 WebGL 使用 Y-up
const rotationMatrix = mat4.rotateX(-Math.PI / 2);
2. 模型太大或太小?
在加载模型后,计算其包围盒并进行缩放:
function calculateBoundingBox(positions) {let min = [Infinity, Infinity, Infinity];let max = [-Infinity, -Infinity, -Infinity];for (let i = 0; i < positions.length; i += 3) {min[0] = Math.min(min[0], positions[i]);min[1] = Math.min(min[1], positions[i + 1]);min[2] = Math.min(min[2], positions[i + 2]);max[0] = Math.max(max[0], positions[i]);max[1] = Math.max(max[1], positions[i + 1]);max[2] = Math.max(max[2], positions[i + 2]);}return { min, max };
}
3. 性能问题?
- 使用
gl.STATIC_DRAW
而不是gl.DYNAMIC_DRAW
(如果数据不会改变) - 考虑使用索引缓冲区来减少重复顶点
- 对于动画模型,考虑使用顶点着色器进行变换
总结
在本教程中,我们学习了:
- OBJ 文件格式的基本结构和语法
- 如何编写一个简单的 OBJ 解析器
- 如何将解析后的模型数据传递给 WebGL
- 如何渲染加载的 3D 模型
- 性能优化和材质支持的进阶技巧
现在,你已经掌握了加载和渲染外部 3D 模型的能力,可以在你的 WebGL 应用中展示更复杂、更精美的 3D 内容了!
在下一篇教程中,我们将探索 WebGL 框架(如 Three.js),看看它们如何简化我们的开发流程,以及何时应该选择使用框架而不是原生 WebGL。
练习
- 尝试从免费模型网站下载一个 OBJ 模型,并在你的应用中加载它
- 为解析器添加错误处理,当 OBJ 文件格式不正确时给出友好的提示
- 实现一个简单的 MTL 解析器,让模型能够显示正确的材质颜色
- 添加一个文件上传功能,让用户可以加载本地的 OBJ 文件
继续探索,享受创造 3D 世界的乐趣!