在前面的教程中,我们学会了如何手动定义顶点数据来创建简单的几何体,比如三角形和立方体。但是,如果我们想要渲染更复杂的模型------比如一个人物角色、一辆汽车或者一个精细的建筑------手动编写成千上万个顶点数据显然是不现实的。
这就是为什么我们需要学习如何加载外部 3D 模型文件。在这篇教程中,我们将探索如何在 WebGL 中加载和渲染 .OBJ 格式的 3D 模型,让我们的应用能够展示专业 3D 建模软件创建的复杂模型。
为什么选择 OBJ 格式?
在众多 3D 模型格式中(如 FBX、GLTF、Collada 等),我们选择 OBJ 格式作为入门的原因有:
- 简单易懂: OBJ 是纯文本格式,可以用任何文本编辑器打开查看
 - 广泛支持: 几乎所有 3D 建模软件都支持导出 OBJ 格式
 - 无需额外库: 解析逻辑相对简单,适合学习底层原理
 - 社区资源丰富: 网上有大量免费的 OBJ 模型可供下载
 
注意: 虽然 OBJ 格式适合学习,但在生产环境中,GLTF 格式因其对动画、材质等特性的更好支持而更受欢迎。
OBJ 文件格式解析
让我们先了解 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 文件解析器:
            
            
              javascript
              
              
            
          
          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 文件:
            
            
              javascript
              
              
            
          
          /**
 * 从 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。这个过程与我们之前手动创建几何体的过程相同:
            
            
              javascript
              
              
            
          
          /**
 * 创建模型的 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
  };
}
        渲染加载的模型
现在让我们把所有部分整合起来,创建一个完整的渲染循环:
            
            
              javascript
              
              
            
          
          /**
 * 设置顶点属性
 */
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 示例中:
            
            
              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.0
vt 0.0 0.0
vt 1.0 0.0
vt 1.0 1.0
vt 0.0 1.0
vn  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 文件没有提供法向量数据,我们需要自己计算:
            
            
              javascript
              
              
            
          
          /**
 * 计算平面法向量
 */
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): 不渲染视野外的对象
 
            
            
              javascript
              
              
            
          
          // 使用 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) 文件:
            
            
              javascript
              
              
            
          
          // 解析 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 (sketchfab.com) - 可以下载很多免费模型
 - Free3D (free3d.com) - 大量免费 3D 模型
 - TurboSquid (www.turbosquid.com/Search/3D-M...) - 有免费模型区域
 - CGTrader (www.cgtrader.com/free-3d-mod...) - 免费 3D 模型市场
 
常见问题
1. 模型显示不完整或倒置?
这可能是坐标系的问题。不同的 3D 软件使用不同的坐标系统(Y-up vs Z-up)。你可能需要在加载后对模型进行变换:
            
            
              javascript
              
              
            
          
          // 如果模型是 Z-up,而 WebGL 使用 Y-up
const rotationMatrix = mat4.rotateX(-Math.PI / 2);
        2. 模型太大或太小?
在加载模型后,计算其包围盒并进行缩放:
            
            
              javascript
              
              
            
          
          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 文件