在前面的教程中,我们学会了如何手动定义顶点数据来创建简单的几何体,比如三角形和立方体。但是,如果我们想要渲染更复杂的模型------比如一个人物角色、一辆汽车或者一个精细的建筑------手动编写成千上万个顶点数据显然是不现实的。
这就是为什么我们需要学习如何加载外部 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 文件