HarmonyOS游戏开发:3D模型加载与渲染(glTF/OBJ)

举报
Jack20 发表于 2026/06/22 21:23:50 2026/06/22
【摘要】 HarmonyOS游戏开发:3D模型加载与渲染(glTF/OBJ)📌 核心要点:掌握glTF和OBJ两种主流3D模型格式的解析流程,实现网格数据、材质系统与骨骼动画的完整加载管线,并在HarmonyOS上完成3D模型的实时渲染。 一、背景与动机上一篇文章我们学会了用OpenGL ES画立方体——但总不能所有3D模型都手写顶点数据吧?实际游戏里,角色、场景、道具都是美术用Blender/M...

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 → accessorbufferView.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加载器让开发者无需手写解析器。但理解底层原理的价值不会消失——当系统模块无法满足需求时,你仍然需要回到"手动解析"的路上。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。