HarmonyOS游戏开发:3D模型加载与渲染(glTF/OBJ)
HarmonyOS游戏开发:3D模型加载与渲染(glTF/OBJ)
📌 核心要点:掌握glTF和OBJ两种主流3D模型格式的解析流程,实现网格数据、材质系统与骨骼动画的完整加载管线,并在HarmonyOS上完成3D模型的实时渲染。
一、背景与动机
上一篇文章我们学会了用OpenGL ES画立方体——但总不能所有3D模型都手写顶点数据吧?实际游戏里,角色、场景、道具都是美术用Blender/Maya等工具建模导出的,开发者需要把这些模型文件加载到应用中渲染出来。
这就引出了3D模型加载这个核心课题。
你可能会想:不就一个文件读取嘛,有多难?实际上,3D模型加载的复杂度远超想象。一个glTF文件可能包含几十个Mesh、上百个材质、复杂的骨骼层级、Morph Target变形目标、动画剪辑……把这些数据正确解析、组织、送入GPU渲染,本身就是一条完整的管线。
更关键的是,模型格式的选择直接影响开发效率。OBJ格式简单但功能有限,glTF格式强大但解析复杂。在HarmonyOS生态中,目前没有像Unity那样内置的模型加载器,你得自己实现或者移植第三方库。所以理解模型格式的内部结构,是做好3D开发的基本功。
二、核心原理
2.1 3D模型格式对比
| 特性 | OBJ | glTF 2.0 |
|---|---|---|
| 文件结构 | 纯文本,逐行解析 | JSON + 二进制缓冲 |
| 网格数据 | ✅ 顶点/法线/UV | ✅ 完整支持 |
| 材质系统 | 基础MTL材质 | PBR材质(金属度-粗糙度工作流) |
| 骨骼动画 | ❌ 不支持 | ✅ 完整支持 |
| 纹理嵌入 | ❌ 需外部文件 | ✅ 可嵌入Base64或引用外部 |
| 文件大小 | 较大(纯文本) | 较小(二进制缓冲) |
| 解析难度 | 低 | 中高 |
| 行业地位 | 传统格式,逐渐淘汰 | Web 3D标准,主流方向 |
2.2 glTF文件结构
glTF采用"JSON描述 + 二进制数据"的分离式结构:
graph TD
A[glTF文件]:::primary --> B[JSON描述层<br/>*.gltf]:::info
A --> C[二进制缓冲层<br/>*.bin]:::warning
A --> D[外部资源<br/>纹理图片]:::success
B --> B1[scenes 场景树]:::info
B --> B2[nodes 节点层级]:::info
B --> B3[meshes 网格定义]:::info
B --> B4[materials 材质定义]:::info
B --> B5[accessors 数据访问器]:::info
B --> B6[bufferViews 缓冲视图]:::info
B --> B7[animations 动画剪辑]:::info
B --> B8[skins 骨骼蒙皮]:::info
C --> C1[顶点坐标数据]:::warning
C --> C2[法线数据]:::warning
C --> C3[UV坐标数据]:::warning
C --> C4[骨骼权重数据]:::warning
C --> C5[索引数据]:::warning
classDef primary fill:#4CAF50,stroke:#388E3C,color:#fff
classDef warning fill:#FF9800,stroke:#F57C00,color:#fff
classDef error fill:#F44336,stroke:#D32F2F,color:#fff
classDef info fill:#2196F3,stroke:#1976D2,color:#fff
classDef success fill:#9C27B0,stroke:#7B1FA2,color:#fff
glTF的数据读取链路:buffer → bufferView → accessor → 具体属性。Buffer是原始二进制块,bufferView定义了数据在buffer中的偏移和长度,accessor定义了数据的类型(如VEC3_FLOAT)和数量。
2.3 OBJ文件结构
OBJ格式简单直观,每行一个指令:
# 注释
v 1.0 2.0 3.0 # 顶点坐标
vn 0.0 1.0 0.0 # 顶点法线
vt 0.5 0.5 # 纹理坐标
f 1/1/1 2/2/2 3/3/3 # 面(顶点/纹理/法线 索引)
usemtl MaterialName # 使用的材质
mtllib material.mtl # 引用材质文件
三、代码实战
3.1 基础用法:OBJ模型解析器
先从简单的OBJ格式开始,实现一个基础解析器:
// OBJ模型数据结构
export interface ObjModel {
meshes: ObjMesh[];
materials: Map<string, ObjMaterial>;
}
export interface ObjMesh {
name: string;
vertices: Float32Array; // 顶点坐标 [x,y,z, x,y,z, ...]
normals: Float32Array; // 法线 [nx,ny,nz, ...]
texCoords: Float32Array; // UV [u,v, u,v, ...]
indices: Uint32Array; // 索引
materialName: string;
}
export interface ObjMaterial {
name: string;
ambient: number[]; // Ka 环境光颜色 [r,g,b]
diffuse: number[]; // Kd 漫反射颜色
specular: number[]; // Ks 镜面反射颜色
shininess: number; // Ns 光泽度
diffuseTexture: string; // map_Kd 漫反射贴图路径
normalTexture: string; // map_Bump 法线贴图路径
}
// OBJ解析器
export class ObjParser {
// 解析OBJ文本内容
static parse(objText: string, mtlText?: string): ObjModel {
const lines = objText.split('\n');
// 临时存储(OBJ的顶点/法线/UV索引是全局的,面的索引需要重新映射)
const tempVertices: number[][] = [];
const tempNormals: number[][] = [];
const tempTexCoords: number[][] = [];
// 当前Mesh的数据
let currentMeshName = 'default';
let currentMaterial = '';
const meshDataMap: Map<string, {
vertices: number[]; normals: number[];
texCoords: number[]; indices: number[];
materialName: string;
}> = new Map();
// 初始化默认Mesh
meshDataMap.set(currentMeshName, {
vertices: [], normals: [], texCoords: [], indices: [], materialName: ''
});
// 顶点去重映射表
const vertexCache: Map<string, number> = new Map();
let nextIndex = 0;
for (const rawLine of lines) {
const line = rawLine.trim();
if (line.length === 0 || line.startsWith('#')) continue;
const parts = line.split(/\s+/);
const cmd = parts[0];
switch (cmd) {
case 'v': // 顶点坐标
tempVertices.push([
parseFloat(parts[1]),
parseFloat(parts[2]),
parseFloat(parts[3])
]);
break;
case 'vn': // 法线
tempNormals.push([
parseFloat(parts[1]),
parseFloat(parts[2]),
parseFloat(parts[3])
]);
break;
case 'vt': // 纹理坐标
tempTexCoords.push([
parseFloat(parts[1]),
parseFloat(parts[2])
]);
break;
case 'o': // 对象名称
case 'g': // 组名称
currentMeshName = parts[1] || 'default';
if (!meshDataMap.has(currentMeshName)) {
meshDataMap.set(currentMeshName, {
vertices: [], normals: [], texCoords: [], indices: [], materialName: currentMaterial
});
}
break;
case 'usemtl': // 使用材质
currentMaterial = parts[1] || '';
if (meshDataMap.has(currentMeshName)) {
meshDataMap.get(currentMeshName)!.materialName = currentMaterial;
}
break;
case 'f': // 面
this.parseFace(parts, tempVertices, tempNormals, tempTexCoords,
meshDataMap.get(currentMeshName)!, vertexCache, nextIndex);
// 更新nextIndex(简化处理,实际需要精确计算)
nextIndex = vertexCache.size;
break;
}
}
// 组装最终模型数据
const meshes: ObjMesh[] = [];
meshDataMap.forEach((data, name) => {
meshes.push({
name: name,
vertices: new Float32Array(data.vertices),
normals: new Float32Array(data.normals),
texCoords: new Float32Array(data.texCoords),
indices: new Uint32Array(data.indices),
materialName: data.materialName
});
});
// 解析MTL材质
const materials = mtlText ? this.parseMtl(mtlText) : new Map<string, ObjMaterial>();
return { meshes, materials };
}
// 解析面数据(支持 v/vt/vn、v//vn、v/vt、v 四种格式)
private static parseFace(
parts: string[],
tempVertices: number[][],
tempNormals: number[][],
tempTexCoords: number[][],
meshData: { vertices: number[]; normals: number[]; texCoords: number[]; indices: number[] },
vertexCache: Map<string, number>,
nextIndex: number
): void {
// 提取面的顶点索引
const faceIndices: number[] = [];
for (let i = 1; i < parts.length; i++) {
const key = parts[i];
if (vertexCache.has(key)) {
faceIndices.push(vertexCache.get(key)!);
continue;
}
// 解析 v/vt/vn 格式
const segments = key.split('/');
const vIdx = parseInt(segments[0]) - 1; // OBJ索引从1开始
const vtIdx = segments.length > 1 && segments[1] ? parseInt(segments[1]) - 1 : -1;
const vnIdx = segments.length > 2 && segments[2] ? parseInt(segments[2]) - 1 : -1;
// 添加顶点数据
if (vIdx >= 0 && vIdx < tempVertices.length) {
meshData.vertices.push(...tempVertices[vIdx]);
}
if (vnIdx >= 0 && vnIdx < tempNormals.length) {
meshData.normals.push(...tempNormals[vnIdx]);
}
if (vtIdx >= 0 && vtIdx < tempTexCoords.length) {
meshData.texCoords.push(...tempTexCoords[vtIdx]);
}
const newIndex = vertexCache.size;
vertexCache.set(key, newIndex);
faceIndices.push(newIndex);
}
// 三角化(Fan方式,适用于凸多边形)
for (let i = 1; i < faceIndices.length - 1; i++) {
meshData.indices.push(faceIndices[0]);
meshData.indices.push(faceIndices[i]);
meshData.indices.push(faceIndices[i + 1]);
}
}
// 解析MTL材质文件
static parseMtl(mtlText: string): Map<string, ObjMaterial> {
const materials = new Map<string, ObjMaterial>();
let current: ObjMaterial | null = null;
const lines = mtlText.split('\n');
for (const rawLine of lines) {
const line = rawLine.trim();
if (line.length === 0 || line.startsWith('#')) continue;
const parts = line.split(/\s+/);
switch (parts[0]) {
case 'newmtl':
current = {
name: parts[1],
ambient: [0.2, 0.2, 0.2],
diffuse: [0.8, 0.8, 0.8],
specular: [1.0, 1.0, 1.0],
shininess: 32.0,
diffuseTexture: '',
normalTexture: ''
};
materials.set(current.name, current);
break;
case 'Ka':
if (current) current.ambient = [parseFloat(parts[1]), parseFloat(parts[2]), parseFloat(parts[3])];
break;
case 'Kd':
if (current) current.diffuse = [parseFloat(parts[1]), parseFloat(parts[2]), parseFloat(parts[3])];
break;
case 'Ks':
if (current) current.specular = [parseFloat(parts[1]), parseFloat(parts[2]), parseFloat(parts[3])];
break;
case 'Ns':
if (current) current.shininess = parseFloat(parts[1]);
break;
case 'map_Kd':
if (current) current.diffuseTexture = parts[1];
break;
case 'map_Bump':
if (current) current.normalTexture = parts[1];
break;
}
}
return materials;
}
}
3.2 进阶用法:glTF模型解析器
glTF的解析比OBJ复杂得多,需要处理JSON描述层和二进制缓冲层:
// glTF数据结构定义
export interface GltfAsset {
scenes: GltfScene[];
meshes: GltfMesh[];
materials: GltfMaterial[];
textures: GltfTexture[];
images: GltfImage[];
animations: GltfAnimation[];
skins: GltfSkin[];
}
export interface GltfScene {
name: string;
nodes: number[]; // 根节点索引
}
export interface GltfMesh {
name: string;
primitives: GltfPrimitive[];
}
export interface GltfPrimitive {
positions: Float32Array; // 顶点位置
normals?: Float32Array; // 法线
texCoords?: Float32Array; // UV坐标
joints?: Uint16Array; // 骨骼关节索引(4个/顶点)
weights?: Float32Array; // 骨骼权重(4个/顶点)
indices?: Uint32Array; // 索引
materialIndex: number; // 材质索引
mode: number; // 绘制模式(4=TRIANGLES)
}
export interface GltfMaterial {
name: string;
baseColorFactor: number[]; // 基础颜色 [r,g,b,a]
baseColorTexture: number; // 基础颜色贴图索引
metallicFactor: number; // 金属度
roughnessFactor: number; // 粗糙度
normalTexture: number; // 法线贴图索引
emissiveFactor: number[]; // 自发光颜色
alphaMode: string; // 透明模式 OPAQUE/MASK/BLEND
alphaCutoff: number; // Alpha裁剪阈值
doubleSided: boolean; // 双面渲染
}
export interface GltfTexture {
sourceIndex: number; // 图片索引
samplerIndex: number; // 采样器索引
}
export interface GltfImage {
uri: string; // 图片路径或空(表示缓冲区数据)
bufferViewIndex: number; // 缓冲区视图索引
mimeType: string; // MIME类型
}
export interface GltfAnimation {
name: string;
channels: GltfAnimationChannel[];
duration: number;
}
export interface GltfAnimationChannel {
targetNode: number;
targetPath: string; // translation/rotation/scale/weights
sampler: GltfAnimationSampler;
}
export interface GltfAnimationSampler {
input: Float32Array; // 时间关键帧
output: Float32Array; // 值关键帧
interpolation: string; // LINEAR/STEP/CUBICSPLINE
}
export interface GltfSkin {
joints: number[]; // 关节节点索引
inverseBindMatrices: Float32Array; // 逆绑定矩阵
}
// glTF解析器
export class GltfParser {
private json: Record<string, Object> = {};
private binaryBuffer: ArrayBuffer = new ArrayBuffer(0);
// 解析glTF文件(.gltf + .bin)
static async parse(context: Context, gltfPath: string): Promise<GltfAsset> {
const parser = new GltfParser();
// 1. 读取JSON文件
const jsonText = await parser.readTextFile(context, gltfPath);
parser.json = JSON.parse(jsonText);
// 2. 读取二进制缓冲
const buffers = parser.json['buffers'] as Array<Record<string, Object>>;
if (buffers && buffers.length > 0) {
const binUri = buffers[0]['uri'] as string;
if (binUri && !binUri.startsWith('data:')) {
const basePath = gltfPath.substring(0, gltfPath.lastIndexOf('/') + 1);
parser.binaryBuffer = await parser.readBinaryFile(context, basePath + binUri);
}
}
// 3. 解析各个数据段
return parser.buildAsset();
}
// 构建完整资产
private buildAsset(): GltfAsset {
return {
scenes: this.parseScenes(),
meshes: this.parseMeshes(),
materials: this.parseMaterials(),
textures: this.parseTextures(),
images: this.parseImages(),
animations: this.parseAnimations(),
skins: this.parseSkins()
};
}
// 解析网格数据(核心方法)
private parseMeshes(): GltfMesh[] {
const meshDefs = this.json['meshes'] as Array<Record<string, Object>> || [];
const result: GltfMesh[] = [];
for (const meshDef of meshDefs) {
const primitives: GltfPrimitive[] = [];
const primDefs = meshDef['primitives'] as Array<Record<string, Object>> || [];
for (const primDef of primDefs) {
const attributes = primDef['attributes'] as Record<string, number> || {};
const primitive: GltfPrimitive = {
positions: this.readAccessorData(attributes['POSITION'] ?? -1) as Float32Array,
normals: attributes['NORMAL'] !== undefined
? this.readAccessorData(attributes['NORMAL']) as Float32Array : undefined,
texCoords: attributes['TEXCOORD_0'] !== undefined
? this.readAccessorData(attributes['TEXCOORD_0']) as Float32Array : undefined,
joints: attributes['JOINTS_0'] !== undefined
? this.readAccessorData(attributes['JOINTS_0']) as Uint16Array : undefined,
weights: attributes['WEIGHTS_0'] !== undefined
? this.readAccessorData(attributes['WEIGHTS_0']) as Float32Array : undefined,
indices: primDef['indices'] !== undefined
? this.readAccessorData(primDef['indices'] as number) as Uint32Array : undefined,
materialIndex: (primDef['material'] as number) ?? 0,
mode: (primDef['mode'] as number) ?? 4
};
primitives.push(primitive);
}
result.push({
name: (meshDef['name'] as string) || `mesh_${result.length}`,
primitives
});
}
return result;
}
// 通过accessor索引读取数据(核心数据读取链路)
private readAccessorData(accessorIndex: number): Float32Array | Uint32Array | Uint16Array {
if (accessorIndex < 0) return new Float32Array(0);
const accessors = this.json['accessors'] as Array<Record<string, Object>>;
const accessor = accessors[accessorIndex];
const bufferViewIndex = accessor['bufferView'] as number;
const componentType = accessor['componentType'] as number; // 5126=FLOAT, 5125=UNSIGNED_INT, 5123=UNSIGNED_SHORT
const count = accessor['count'] as number;
const type = accessor['type'] as string; // SCALAR, VEC2, VEC3, VEC4, MAT4
// 计算每个元素的分量数
const componentCount = this.getTypeComponentCount(type);
// 读取bufferView
const bufferViews = this.json['bufferViews'] as Array<Record<string, Object>>;
const bufferView = bufferViews[bufferViewIndex];
const byteOffset = (bufferView['byteOffset'] as number) ?? 0;
const byteLength = bufferView['byteLength'] as number;
// 从二进制缓冲中提取数据
const accessorOffset = (accessor['byteOffset'] as number) ?? 0;
const totalOffset = byteOffset + accessorOffset;
const dataView = new DataView(this.binaryBuffer, totalOffset, byteLength - accessorOffset);
// 根据组件类型创建对应类型的数组
if (componentType === 5126) { // FLOAT
const result = new Float32Array(count * componentCount);
for (let i = 0; i < result.length; i++) {
result[i] = dataView.getFloat32(i * 4, true); // little-endian
}
return result;
} else if (componentType === 5125) { // UNSIGNED_INT
const result = new Uint32Array(count * componentCount);
for (let i = 0; i < result.length; i++) {
result[i] = dataView.getUint32(i * 4, true);
}
return result;
} else if (componentType === 5123) { // UNSIGNED_SHORT
const result = new Uint16Array(count * componentCount);
for (let i = 0; i < result.length; i++) {
result[i] = dataView.getUint16(i * 2, true);
}
return result;
}
return new Float32Array(0);
}
// 获取类型的分量数
private getTypeComponentCount(type: string): number {
const map: Record<string, number> = {
'SCALAR': 1, 'VEC2': 2, 'VEC3': 3, 'VEC4': 4, 'MAT4': 16
};
return map[type] ?? 1;
}
// 解析材质
private parseMaterials(): GltfMaterial[] {
const materialDefs = this.json['materials'] as Array<Record<string, Object>> || [];
const result: GltfMaterial[] = [];
for (const matDef of materialDefs) {
const pbr = matDef['pbrMetallicRoughness'] as Record<string, Object> || {};
const baseColorTexture = pbr['baseColorTexture'] as Record<string, Object> || {};
result.push({
name: (matDef['name'] as string) || `material_${result.length}`,
baseColorFactor: (pbr['baseColorFactor'] as number[]) || [1, 1, 1, 1],
baseColorTexture: (baseColorTexture['index'] as number) ?? -1,
metallicFactor: (pbr['metallicFactor'] as number) ?? 1.0,
roughnessFactor: (pbr['roughnessFactor'] as number) ?? 1.0,
normalTexture: ((matDef['normalTexture'] as Record<string, Object>)?.['index'] as number) ?? -1,
emissiveFactor: (matDef['emissiveFactor'] as number[]) || [0, 0, 0],
alphaMode: (matDef['alphaMode'] as string) || 'OPAQUE',
alphaCutoff: (matDef['alphaCutoff'] as number) ?? 0.5,
doubleSided: (matDef['doubleSided'] as boolean) ?? false
});
}
return result;
}
// 解析动画数据
private parseAnimations(): GltfAnimation[] {
const animDefs = this.json['animations'] as Array<Record<string, Object>> || [];
const result: GltfAnimation[] = [];
for (const animDef of animDefs) {
const channels: GltfAnimationChannel[] = [];
const channelDefs = animDef['channels'] as Array<Record<string, Object>> || [];
const samplerDefs = animDef['samplers'] as Array<Record<string, Object>> || [];
let maxTime = 0;
for (const channelDef of channelDefs) {
const target = channelDef['target'] as Record<string, Object>;
const samplerDef = samplerDefs[channelDef['sampler'] as number];
const input = this.readAccessorData(samplerDef['input'] as number) as Float32Array;
const output = this.readAccessorData(samplerDef['output'] as number) as Float32Array;
// 计算动画时长
for (let i = 0; i < input.length; i++) {
if (input[i] > maxTime) maxTime = input[i];
}
channels.push({
targetNode: target['node'] as number,
targetPath: target['path'] as string,
sampler: {
input: input,
output: output,
interpolation: (samplerDef['interpolation'] as string) || 'LINEAR'
}
});
}
result.push({
name: (animDef['name'] as string) || `animation_${result.length}`,
channels: channels,
duration: maxTime
});
}
return result;
}
// 解析骨骼蒙皮
private parseSkins(): GltfSkin[] {
const skinDefs = this.json['skins'] as Array<Record<string, Object>> || [];
const result: GltfSkin[] = [];
for (const skinDef of skinDefs) {
result.push({
joints: skinDef['joints'] as number[],
inverseBindMatrices: skinDef['inverseBindMatrices'] !== undefined
? this.readAccessorData(skinDef['inverseBindMatrices'] as number) as Float32Array
: new Float32Array(0)
});
}
return result;
}
// 以下为简化方法,实际需要完整实现
private parseScenes(): GltfScene[] { return []; }
private parseTextures(): GltfTexture[] { return []; }
private parseImages(): GltfImage[] { return []; }
// 文件读取辅助
private async readTextFile(context: Context, path: string): Promise<string> {
// 使用@ohos.file.fs读取
return '';
}
private async readBinaryFile(context: Context, path: string): Promise<ArrayBuffer> {
return new ArrayBuffer(0);
}
}
3.3 完整示例:3D模型加载与渲染
将解析器与OpenGL ES渲染结合,实现完整的模型加载渲染流程:
import { GltfParser, GltfAsset, GltfMesh, GltfPrimitive, GltfAnimation, ObjParser, ObjModel } from './ModelParser'
// 渲染器:管理GPU缓冲和绘制
export class ModelRenderer {
private gl: WebGL2RenderingContext | null = null;
private meshBuffers: Map<string, MeshBuffers> = new Map();
private textures: Map<number, WebGLTexture> = new Map();
private program: WebGLProgram | null = null;
// 初始化渲染器
initialize(gl: WebGL2RenderingContext): void {
this.gl = gl;
// 编译着色器程序
this.program = this.createProgram(VERTEX_SHADER_SRC, FRAGMENT_SHADER_SRC);
}
// 加载glTF模型到GPU
loadGltfModel(asset: GltfAsset): void {
if (!this.gl) return;
for (let i = 0; i < asset.meshes.length; i++) {
const mesh = asset.meshes[i];
for (let j = 0; j < mesh.primitives.length; j++) {
const prim = mesh.primitives[j];
const key = `${mesh.name}_prim${j}`;
this.uploadPrimitive(key, prim);
}
}
}
// 上传单个Primitive到GPU
private uploadPrimitive(key: string, prim: GltfPrimitive): void {
const gl = this.gl!;
const vao = gl.createVertexArray()!;
gl.bindVertexArray(vao);
// 顶点位置
const posVbo = gl.createBuffer()!;
gl.bindBuffer(gl.ARRAY_BUFFER, posVbo);
gl.bufferData(gl.ARRAY_BUFFER, prim.positions, gl.STATIC_DRAW);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 0, 0);
// 法线
let normalVbo: WebGLBuffer | null = null;
if (prim.normals) {
normalVbo = gl.createBuffer()!;
gl.bindBuffer(gl.ARRAY_BUFFER, normalVbo);
gl.bufferData(gl.ARRAY_BUFFER, prim.normals, gl.STATIC_DRAW);
gl.enableVertexAttribArray(1);
gl.vertexAttribPointer(1, 3, gl.FLOAT, false, 0, 0);
}
// UV坐标
let uvVbo: WebGLBuffer | null = null;
if (prim.texCoords) {
uvVbo = gl.createBuffer()!;
gl.bindBuffer(gl.ARRAY_BUFFER, uvVbo);
gl.bufferData(gl.ARRAY_BUFFER, prim.texCoords, gl.STATIC_DRAW);
gl.enableVertexAttribArray(2);
gl.vertexAttribPointer(2, 2, gl.FLOAT, false, 0, 0);
}
// 骨骼数据
let jointVbo: WebGLBuffer | null = null;
let weightVbo: WebGLBuffer | null = null;
if (prim.joints && prim.weights) {
jointVbo = gl.createBuffer()!;
gl.bindBuffer(gl.ARRAY_BUFFER, jointVbo);
gl.bufferData(gl.ARRAY_BUFFER, prim.joints, gl.STATIC_DRAW);
gl.enableVertexAttribArray(3);
gl.vertexAttribPointer(3, 4, gl.UNSIGNED_SHORT, false, 0, 0);
weightVbo = gl.createBuffer()!;
gl.bindBuffer(gl.ARRAY_BUFFER, weightVbo);
gl.bufferData(gl.ARRAY_BUFFER, prim.weights, gl.STATIC_DRAW);
gl.enableVertexAttribArray(4);
gl.vertexAttribPointer(4, 4, gl.FLOAT, false, 0, 0);
}
// 索引缓冲
let ebo: WebGLBuffer | null = null;
let indexCount = 0;
if (prim.indices) {
ebo = gl.createBuffer()!;
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ebo);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, prim.indices, gl.STATIC_DRAW);
indexCount = prim.indices.length;
} else {
indexCount = prim.positions.length / 3;
}
gl.bindVertexArray(0);
this.meshBuffers.set(key, {
vao, posVbo, normalVbo, uvVbo, jointVbo, weightVbo, ebo,
indexCount, hasIndices: prim.indices !== undefined,
materialIndex: prim.materialIndex,
mode: prim.mode
});
}
// 渲染所有网格
render(viewMatrix: Float32Array, projectionMatrix: Float32Array): void {
const gl = this.gl!;
gl.useProgram(this.program);
// 设置矩阵Uniform
const viewLoc = gl.getUniformLocation(this.program!, 'uViewMatrix');
const projLoc = gl.getUniformLocation(this.program!, 'uProjectionMatrix');
gl.uniformMatrix4fv(viewLoc, false, viewMatrix);
gl.uniformMatrix4fv(projLoc, false, projectionMatrix);
// 遍历所有网格缓冲并绘制
this.meshBuffers.forEach((buffers) => {
gl.bindVertexArray(buffers.vao);
// 绑定材质纹理
const texLoc = gl.getUniformLocation(this.program!, 'uTexture');
gl.uniform1i(texLoc, 0);
const tex = this.textures.get(buffers.materialIndex);
if (tex) {
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, tex);
}
// 绘制
if (buffers.hasIndices) {
gl.drawElements(buffers.mode, buffers.indexCount, gl.UNSIGNED_INT, 0);
} else {
gl.drawArrays(buffers.mode, 0, buffers.indexCount);
}
gl.bindVertexArray(0);
});
}
// 清理资源
cleanup(): void {
const gl = this.gl;
if (!gl) return;
this.meshBuffers.forEach((buf) => {
gl.deleteVertexArray(buf.vao);
gl.deleteBuffer(buf.posVbo);
if (buf.normalVbo) gl.deleteBuffer(buf.normalVbo);
if (buf.uvVbo) gl.deleteBuffer(buf.uvVbo);
if (buf.jointVbo) gl.deleteBuffer(buf.jointVbo);
if (buf.weightVbo) gl.deleteBuffer(buf.weightVbo);
if (buf.ebo) gl.deleteBuffer(buf.ebo);
});
this.textures.forEach((tex) => gl.deleteTexture(tex));
if (this.program) gl.deleteProgram(this.program);
}
}
// 网格缓冲数据
interface MeshBuffers {
vao: WebGLVertexArrayObject;
posVbo: WebGLBuffer;
normalVbo: WebGLBuffer | null;
uvVbo: WebGLBuffer | null;
jointVbo: WebGLBuffer | null;
weightVbo: WebGLBuffer | null;
ebo: WebGLBuffer | null;
indexCount: number;
hasIndices: boolean;
materialIndex: number;
mode: number;
}
3.4 骨骼动画播放
骨骼动画是glTF的核心能力之一,需要在着色器中计算蒙皮变形:
// 骨骼蒙皮顶点着色器
#version 300 es
layout(location = 0) in vec3 aPosition;
layout(location = 1) in vec3 aNormal;
layout(location = 2) in vec2 aTexCoord;
layout(location = 3) in ivec4 aJoints; // 骨骼关节索引
layout(location = 4) in vec4 aWeights; // 骨骼权重
uniform mat4 uModelMatrix;
uniform mat4 uViewMatrix;
uniform mat4 uProjectionMatrix;
uniform mat4 uJointMatrices[64]; // 最多64个骨骼矩阵
out vec3 vNormal;
out vec2 vTexCoord;
out vec3 vFragPos;
void main() {
// 计算蒙皮矩阵:4个骨骼的加权混合
mat4 skinMatrix =
aWeights.x * uJointMatrices[aJoints.x] +
aWeights.y * uJointMatrices[aJoints.y] +
aWeights.z * uJointMatrices[aJoints.z] +
aWeights.w * uJointMatrices[aJoints.w];
// 应用蒙皮变换
vec4 skinnedPos = skinMatrix * vec4(aPosition, 1.0);
vec4 skinnedNormal = skinMatrix * vec4(aNormal, 0.0);
vec4 worldPos = uModelMatrix * skinnedPos;
vFragPos = worldPos.xyz;
vNormal = mat3(transpose(inverse(uModelMatrix))) * skinnedNormal.xyz;
vTexCoord = aTexCoord;
gl_Position = uProjectionMatrix * uViewMatrix * worldPos;
}
// 动画播放器
export class AnimationPlayer {
private animations: GltfAnimation[] = [];
private currentAnimation: number = -1;
private currentTime: number = 0;
private playing: boolean = false;
private loop: boolean = true;
// 动画矩阵输出(每帧更新)
private jointMatrices: Float32Array = new Float32Array(64 * 16); // 64个4x4矩阵
setAnimations(animations: GltfAnimation[]): void {
this.animations = animations;
}
play(index: number = 0): void {
if (index < this.animations.length) {
this.currentAnimation = index;
this.currentTime = 0;
this.playing = true;
}
}
stop(): void {
this.playing = false;
this.currentTime = 0;
}
// 每帧更新动画
update(deltaTime: number): Float32Array {
if (!this.playing || this.currentAnimation < 0) {
return this.jointMatrices;
}
const anim = this.animations[this.currentAnimation];
this.currentTime += deltaTime;
// 循环播放
if (this.currentTime >= anim.duration) {
if (this.loop) {
this.currentTime = this.currentTime % anim.duration;
} else {
this.currentTime = anim.duration;
this.playing = false;
}
}
// 更新每个通道的变换
for (const channel of anim.channels) {
const sampler = channel.sampler;
const t = this.currentTime;
// 找到当前时间所在的区间
let lowerIdx = 0;
for (let i = 0; i < sampler.input.length - 1; i++) {
if (t >= sampler.input[i] && t < sampler.input[i + 1]) {
lowerIdx = i;
break;
}
}
// 计算插值因子
const lowerTime = sampler.input[lowerIdx];
const upperTime = sampler.input[lowerIdx + 1] || lowerTime;
const factor = upperTime > lowerTime
? (t - lowerTime) / (upperTime - lowerTime) : 0;
// 根据目标路径插值
switch (channel.targetPath) {
case 'translation':
this.interpolateVec3(channel.targetNode, sampler.output, lowerIdx, factor);
break;
case 'rotation':
this.interpolateQuat(channel.targetNode, sampler.output, lowerIdx, factor);
break;
case 'scale':
this.interpolateVec3(channel.targetNode, sampler.output, lowerIdx, factor);
break;
}
}
return this.jointMatrices;
}
// 3D向量线性插值
private interpolateVec3(nodeIdx: number, output: Float32Array, keyIdx: number, factor: number): void {
const offset = keyIdx * 3;
const nextOffset = offset + 3;
const x = output[offset] + (output[nextOffset] - output[offset]) * factor;
const y = output[offset + 1] + (output[nextOffset + 1] - output[offset + 1]) * factor;
const z = output[offset + 2] + (output[nextOffset + 2] - output[offset + 2]) * factor;
// 更新节点变换矩阵的平移分量
const matOffset = nodeIdx * 16;
this.jointMatrices[matOffset + 12] = x;
this.jointMatrices[matOffset + 13] = y;
this.jointMatrices[matOffset + 14] = z;
}
// 四元数球面插值(SLERP)
private interpolateQuat(nodeIdx: number, output: Float32Array, keyIdx: number, factor: number): void {
const offset = keyIdx * 4;
const nextOffset = offset + 4;
const q1 = [output[offset], output[offset + 1], output[offset + 2], output[offset + 3]];
const q2 = [output[nextOffset], output[nextOffset + 1], output[nextOffset + 2], output[nextOffset + 3]];
// SLERP
let dot = q1[0] * q2[0] + q1[1] * q2[1] + q1[2] * q2[2] + q1[3] * q2[3];
if (dot < 0) {
q2[0] = -q2[0]; q2[1] = -q2[1]; q2[2] = -q2[2]; q2[3] = -q2[3];
dot = -dot;
}
let result: number[];
if (dot > 0.9995) {
// 角度太小,用线性插值
result = [
q1[0] + factor * (q2[0] - q1[0]),
q1[1] + factor * (q2[1] - q1[1]),
q1[2] + factor * (q2[2] - q1[2]),
q1[3] + factor * (q2[3] - q1[3])
];
} else {
const theta0 = Math.acos(dot);
const theta = theta0 * factor;
const sinTheta = Math.sin(theta);
const sinTheta0 = Math.sin(theta0);
const s1 = Math.cos(theta) - dot * sinTheta / sinTheta0;
const s2 = sinTheta / sinTheta0;
result = [
s1 * q1[0] + s2 * q2[0],
s1 * q1[1] + s2 * q2[1],
s1 * q1[2] + s2 * q2[2],
s1 * q1[3] + s2 * q2[3]
];
}
// 四元数转旋转矩阵
const [qx, qy, qz, qw] = result;
const matOffset = nodeIdx * 16;
this.jointMatrices[matOffset + 0] = 1 - 2 * (qy * qy + qz * qz);
this.jointMatrices[matOffset + 1] = 2 * (qx * qy + qw * qz);
this.jointMatrices[matOffset + 2] = 2 * (qx * qz - qw * qy);
this.jointMatrices[matOffset + 4] = 2 * (qx * qy - qw * qz);
this.jointMatrices[matOffset + 5] = 1 - 2 * (qx * qx + qz * qz);
this.jointMatrices[matOffset + 6] = 2 * (qy * qz + qw * qx);
this.jointMatrices[matOffset + 8] = 2 * (qx * qz + qw * qy);
this.jointMatrices[matOffset + 9] = 2 * (qy * qz - qw * qx);
this.jointMatrices[matOffset + 10] = 1 - 2 * (qx * qx + qy * qy);
}
}
四、踩坑与注意事项
1. OBJ面索引的"全局vs局部"问题
OBJ的顶点/法线/UV索引是全局的(从文件开头开始编号),但面的索引格式f v/vt/vn中,v、vt、vn的索引可能不同。这意味着你不能直接用索引缓冲来引用顶点数组,因为同一个顶点可能在不同面中有不同的UV或法线。必须对v/vt/vn组合做去重,生成新的顶点数组。
2. glTF的accessor.byteOffset是相对于bufferView的
glTF的数据读取链路是buffer → bufferView → accessor。bufferView.byteOffset是相对于buffer的,而accessor.byteOffset是相对于bufferView的。不要把两个偏移搞混,否则读取的数据会错位,模型会"炸开"。
3. glTF的索引类型可能是Uint16或Uint32
glTF的索引数据类型由accessor的componentType决定,可能是5123(UNSIGNED_SHORT)或5125(UNSIGNED_INT)。上传到GPU时,gl.drawElements的type参数必须与实际数据类型匹配。如果数据是Uint16但用UNSIGNED_INT绘制,索引会越界导致崩溃。
4. 骨骼权重归一化
glTF规范要求每个顶点的4个骨骼权重之和为1.0,但很多导出器并不严格遵守。权重不归一化会导致蒙皮变形出现"膨胀"或"收缩"。建议在加载时检查并强制归一化:w_i = w_i / (w_0 + w_1 + w_2 + w_3)。
5. 四元数插值的方向一致性
SLERP插值要求两个四元数的点积为正。如果点积为负,说明两个四元数指向相反方向,直接插值会走"远路"。必须在插值前取反其中一个四元数,确保走"近路"。
6. 大文件加载的内存管理
一个复杂glTF模型的二进制缓冲可能有几十MB。如果在ArkTS层用JSON.parse解析JSON、用DataView读取二进制,会创建大量临时对象,导致GC频繁触发。建议:将模型解析放在C++层(NAPI),直接操作内存,避免ArkTS层的对象创建开销。
7. 纹理路径的跨平台问题
glTF中的纹理路径可能是相对路径(如textures/diffuse.png),在Windows上用反斜杠、在HarmonyOS上用正斜杠。加载纹理时必须统一路径分隔符,否则文件找不到。
五、HarmonyOS 6适配说明
API差异
| API | HarmonyOS 5.0 | HarmonyOS 6.0 | 迁移建议 |
|---|---|---|---|
| 文件读取 | @ohos.fileio | @ohos.file.fs | 迁移到fs模块 |
| XComponent | SURFACE模式 | 新增GRAPHIC_3D模式 | 使用新模式简化EGL管理 |
| 模型加载 | 需自行实现解析器 | 新增@ohos.graphics3d模块 | 优先使用系统3D模块 |
| 纹理压缩 | 仅ETC2 | 新增ASTC支持 | 使用ASTC获得更好质量 |
| 内存管理 | 手动管理 | 新增GraphicsMemory管理器 | 使用系统内存池减少GC |
行为变更
- 系统3D模块:HarmonyOS 6.0新增
@ohos.graphics3d模块,内置glTF加载器,支持PBR材质和骨骼动画 - 异步加载管线:6.0的模型加载支持全异步,不阻塞主线程,大模型加载时UI不会卡顿
- GPU内存管理:6.0新增
GraphicsMemory管理器,可以预分配GPU内存池,减少运行时内存分配
适配代码
// HarmonyOS 6.0 使用系统3D模块加载glTF
import { graphics3d } from '@ohos.graphics3d'
@Entry
@Component
struct ModelViewer6Page {
private scene: graphics3d.Scene | null = null
@State loading: boolean = true
async aboutToAppear() {
// 使用系统3D模块加载模型
this.scene = await graphics3d.createScene({
background: { color: '#0A0A1A' }
})
// 加载glTF模型(系统内置解析器)
const model = await this.scene.loadModel('models/character/character.gltf', {
async: true, // 异步加载
autoPlayAnimation: 0, // 自动播放第0个动画
scale: 1.0
})
// 设置相机
this.scene.setCamera({
position: [0, 1.5, 3],
target: [0, 1, 0],
fov: 60
})
// 设置灯光
this.scene.addLight({
type: 'directional',
color: '#FFFFFF',
intensity: 1.0,
direction: [-1, -1, -1]
})
this.loading = false
}
build() {
Stack() {
if (this.loading) {
LoadingProgress()
.width(48)
.height(48)
.color('#6C63FF')
}
if (this.scene) {
// 使用系统3D渲染组件
graphics3d.RenderView({
scene: this.scene!,
width: '100%',
height: '100%'
})
}
}
.width('100%')
.height('100%')
.backgroundColor('#0F0F23')
}
}
六、总结
| 维度 | 评价 |
|---|---|
| 学习难度 | ⭐⭐⭐⭐⭐ |
| 使用频率 | ⭐⭐⭐⭐⭐ |
| 重要程度 | ⭐⭐⭐⭐⭐ |
3D模型加载是连接"美术资产"和"程序渲染"的桥梁。理解了glTF和OBJ的文件结构,你就掌握了这条桥梁的建造方法。OBJ格式简单直观,适合入门学习和简单场景;glTF格式功能完整,是现代3D应用的标准选择。
模型加载的核心挑战在于数据解析的正确性和性能。glTF的buffer → bufferView → accessor三级数据读取链路、OBJ的全局索引与局部索引映射、骨骼动画的四元数SLERP插值——每一个环节都有容易踩的坑。建议从OBJ入手理解基本概念,再逐步过渡到glTF的完整实现。
展望未来,HarmonyOS 6.0的@ohos.graphics3d模块将大幅降低3D开发的门槛,内置的glTF加载器让开发者无需手写解析器。但理解底层原理的价值不会消失——当系统模块无法满足需求时,你仍然需要回到"手动解析"的路上。
- 点赞
- 收藏
- 关注作者
评论(0)