第 8 篇:更广阔的世界 - 加载 3D 模型

在前面的教程中,我们学会了如何手动定义顶点数据来创建简单的几何体,比如三角形和立方体。但是,如果我们想要渲染更复杂的模型------比如一个人物角色、一辆汽车或者一个精细的建筑------手动编写成千上万个顶点数据显然是不现实的。

这就是为什么我们需要学习如何加载外部 3D 模型文件。在这篇教程中,我们将探索如何在 WebGL 中加载和渲染 .OBJ 格式的 3D 模型,让我们的应用能够展示专业 3D 建模软件创建的复杂模型。

为什么选择 OBJ 格式?

在众多 3D 模型格式中(如 FBX、GLTF、Collada 等),我们选择 OBJ 格式作为入门的原因有:

  1. 简单易懂: OBJ 是纯文本格式,可以用任何文本编辑器打开查看
  2. 广泛支持: 几乎所有 3D 建模软件都支持导出 OBJ 格式
  3. 无需额外库: 解析逻辑相对简单,适合学习底层原理
  4. 社区资源丰富: 网上有大量免费的 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;
  }
}

解析器的关键点:

  1. 顶点去重 : 使用 Map 来追踪已处理的顶点组合,避免重复数据
  2. 索引转换: 将 OBJ 的 1-based 索引转换为 JavaScript 的 0-based 索引
  3. 三角化: 将可能的四边形或多边形面分解为三角形
  4. 数据展开: 根据面的引用,将顶点属性组合成 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 模型:

  1. Sketchfab (sketchfab.com) - 可以下载很多免费模型
  2. Free3D (free3d.com) - 大量免费 3D 模型
  3. TurboSquid (www.turbosquid.com/Search/3D-M...) - 有免费模型区域
  4. 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。

练习

  1. 尝试从免费模型网站下载一个 OBJ 模型,并在你的应用中加载它
  2. 为解析器添加错误处理,当 OBJ 文件格式不正确时给出友好的提示
  3. 实现一个简单的 MTL 解析器,让模型能够显示正确的材质颜色
  4. 添加一个文件上传功能,让用户可以加载本地的 OBJ 文件
相关推荐
xhload3d4 小时前
智慧钢厂高炉冶炼仿真分析 | 图扑数字孪生
3d·智慧城市·html5·webgl·数字孪生·可视化·热力图·智慧工厂·工业互联网·工业组态·高炉炼铁·数字工厂·高炉炉体·智慧高炉·高炉
m0_728033134 小时前
JavaWeb——(web.xml)中的(url-pattern)
xml·前端
Asort4 小时前
JavaScript设计模式(十二)——代理模式 (Proxy)
前端·javascript·设计模式
简小瑞4 小时前
VSCode源码解密:Event<T> - 类型安全的事件系统
前端·设计模式·visual studio code
寧笙(Lycode)4 小时前
OpenTelemetry 入门
前端
星链引擎4 小时前
智能聊天机器人实践应用版(适合企业 / 项目落地者)
前端
猪哥帅过吴彦祖4 小时前
Flutter 系列教程:列表与网格 - `ListView` 和 `GridView`
前端·flutter·ios
用户352120195604 小时前
React hooks (useRef)
前端
Mintopia4 小时前
⚡当 Next.js 遇上实时通信:Socket.io 与 Pusher 双雄传
前端·后端·全栈