当前位置: 首页 > news >正文

第 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 文件示例:

# 这是注释
# 顶点坐标 (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;}
}

解析器的关键点:

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

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

练习

  1. 尝试从免费模型网站下载一个 OBJ 模型,并在你的应用中加载它
  2. 为解析器添加错误处理,当 OBJ 文件格式不正确时给出友好的提示
  3. 实现一个简单的 MTL 解析器,让模型能够显示正确的材质颜色
  4. 添加一个文件上传功能,让用户可以加载本地的 OBJ 文件

继续探索,享受创造 3D 世界的乐趣!

http://www.dtcms.com/a/482490.html

相关文章:

  • C/C++---_access 和 access 函数 文件/目录状态判断
  • Linux内存管理-缓存系统中的Major和Minor详解
  • 8 读写分离-实战
  • 手机网站建设西安检查网站是否做301
  • 网站Favicon图标:小图标背后的大作用 引言
  • 什么是GEO生成式引擎优化?GEO科普:定义、原理与应用指南
  • 使用 Gensim 进行主题建模(LDA)与词向量训练(Word2Vec)的完整指南
  • 诺奖解码外周免疫耐受,泰克生物以抗体工具链加速机制研究突破
  • 虚幻引擎5 GAS开发俯视角RPG游戏 P05-07 广播效果资产标签
  • 南阳专业做网站抖音代运营平台
  • 网站公司怎么做的好天津海外seo
  • 二级网站建设方案模板做ppt的网站叫什么名字
  • Java优选算法——位运算
  • Linux编辑器vim
  • 大模型-去噪扩散概率模型(DDPM)采样算法详解
  • LeetCode 398:随机数索引
  • 通过公网STUN服务器实现UDP打洞
  • 手机怎样设计网站建设哪个网站有做兼职的
  • 分布式专题——44 ElasticSearch安装
  • Java HTTP编程深度解析:从基础到微服务通信的完整架构实践
  • 3dgs train.py详解
  • Ruby Socket 编程
  • 阿里云linux主机如何添加2个网站中山网站建设方案托管
  • React 状态管理中的循环更新陷阱与解决方案
  • 手机h5免费模板网站深圳网页设计培训要多久
  • 网站快速建设网络营销公司介绍
  • 唐山seo网站建设企业网站的建立如何带来询盘
  • 上海虹口网站建设重庆网站建设公司的网站
  • 自动化测试之 Cucumber 工具
  • 基于MATLAB的t-SNE算法多合成数据集降维可视化实现