想在 HarmonyOS 上做个 3D 模型查看器?先搞懂 Scene 场景管理

你有没有想过,在手机上做一个 3D 模型查看器是什么感觉?用户点一下就能旋转、缩放一个 3D 物体,就像在看一个真实的雕塑一样。听起来很高大上,但其实 HarmonyOS 的 ArkGraphics 3D 模块已经帮你把底层渲染都封装好了,你只需要学会"搭场景"就行。

这篇文章就来聊聊最基础的部分——Scene 场景管理。简单说就是:怎么创建一个 3D 场景,怎么把 glTF 模型加载进去,怎么获取渲染上下文来管理资源。

下面是 Scene 场景创建的两种方式对比:

直接加载模型

从零搭建

需要创建 3D 场景

选择创建方式

Scene.load 加载 glTF/glb

工厂模式创建空场景

获取 Scene 对象

通过 root 访问节点树

getDefaultRenderContext

getRenderResourceFactory

createScene 创建空场景

手动添加相机/灯光/模型

操作节点: 查找/克隆/导入

使用完毕后 destroy 释放资源

先搞清楚整体架构

在 ArkGraphics 3D 里,所有 3D 内容都是围绕"场景(Scene)"来组织的。你可以把 Scene 想象成一个舞台,上面有各种演员(节点)、道具(资源)、灯光(Light)、摄影机(Camera)。而你要做的第一步,就是把这个舞台搭起来。

核心的模块是 @kit.ArkGraphics3D,你需要从里面导入各种类型:

import { SceneResourceParameters, SceneNodeParameters, RaycastResult, RaycastParameters,
  RenderResourceFactory, SceneResourceFactory, SceneComponent, RenderContext, RenderConfiguration,
  RenderParameters, Scene } from '@kit.ArkGraphics3D';

别被这一长串吓到,我们一个一个来认识。

第一步:加载一个 glTF 模型

最简单的起步方式就是用 Scene.load() 把一个现成的 3D 模型加载进来。ArkGraphics 3D 支持 .gltf.glb 两种格式,glb 是把所有数据打包成一个文件,用起来更方便。

import { Scene } from '@kit.ArkGraphics3D';

function loadModel(): void {
  // 加载场景资源,支持.gltf和.glb格式,路径和文件名可根据项目实际资源自定义
  let scene: Promise<Scene> = Scene.load($rawfile("gltf/CubeWithFloor/glTF/AnimatedCube.glb"));
  scene.then((result: Scene) => {
    console.info("Scene loaded, root node: " + result.root?.name);
  });
}

这里 $rawfile() 是 HarmonyOS 提供的 rawfile 资源访问方式,你把 .glb 文件放在项目的 resources/rawfile/ 目录下,就能用相对路径加载。Scene.load() 返回的是一个 Promise,因为加载 3D 模型是个异步操作——模型文件可能很大,需要时间解析。

加载成功后,result 就是一个 Scene 对象了。你可以通过 result.root 拿到整个场景的根节点,通过 result.root?.name 看到根节点叫什么名字。

从应用沙盒目录加载

有时候你的模型文件不是打包在应用里的,而是用户下载的,或者从网络上缓存下来的。这时候你需要用绝对路径加载:

import { common } from '@kit.AbilityKit';
import { fileIo } from '@kit.CoreFileKit';
import { Scene } from '@kit.ArkGraphics3D';

async function loadModelFromAbsolutePath(context: common.UIAbilityContext): Promise<void> {
  // 获取应用沙盒目录(Scene.load仅能读取应用自身写入的文件,不能读取hdc/adb push写入的文件)
  const appCtx = context.getApplicationContext();
  const filesDir = appCtx.filesDir; // /data/storage/el2/base/files

  // 从rawfile读取模型内容(实际使用中也可以替换为其他来源的数据)
  // 使用.glb文件更易于复制加载;若为.gltf,请将其.bin和贴图文件一并复制到同一目录
  const src = 'gltf/CubeWithFloor/glTF/AnimatedCube.glb';
  const load_uri = `${filesDir}/AnimatedCube.glb`;

  // 写入模型文件到应用沙盒目录,生成可被Scene.load(绝对路径)访问的实际文件
  const rawData = await context.resourceManager.getRawFileContent(src);
  const file = fileIo.openSync(load_uri, fileIo.OpenMode.CREATE | fileIo.OpenMode.TRUNC | fileIo.OpenMode.WRITE_ONLY);
  fileIo.writeSync(file.fd, rawData.buffer.slice(rawData.byteOffset, rawData.byteOffset + rawData.byteLength));
  fileIo.closeSync(file);

  // 使用绝对路径加载模型
  Scene.load(load_uri).then((scene: Scene) => {
    // 加载成功后的逻辑处理
  }).catch((error: string) => {
    console.error('Scene load failed: ' + error);
  });
}

这段代码做了这么几件事:先从 rawfile 读出模型的二进制数据,然后写到应用沙盒目录里,最后用绝对路径去加载。注意有个坑:Scene.load 只能读取应用自己写入的文件,你用 hdc push 进去的文件是读不到的,必须通过代码写入才行。

另外如果你的模型是 .gltf 格式(不是 .glb),记得把 .bin 文件和贴图文件一起复制到同一个目录下,不然模型会加载失败。

用工厂模式创建空场景

除了加载现成的模型,你还可以从零开始创建一个空场景,然后自己往里面添加相机、灯光、节点。这就需要用到 RenderResourceFactorySceneResourceFactory 了。

先来看怎么获取渲染上下文和工厂:

import { Scene, RenderContext, RenderResourceFactory } from '@kit.ArkGraphics3D';

// fromFile=true:从指定glb文件加载场景,fromFile=false:创建一个空场景,此参数是为了示例展示两种常见场景创建方式
function createScenePromise(fromFile: boolean = false): Promise<Scene> {
  const renderContext: RenderContext | null = Scene.getDefaultRenderContext();
  if (!renderContext) {
    return Promise.reject(new Error("RenderContext is null"));
  }

  const renderResourceFactory: RenderResourceFactory = renderContext.getRenderResourceFactory();
  if (fromFile) {
    // 创建场景并加载.gltf或.glb文件作为初始内容,路径和名称可根据项目实际资源自定义
    return renderResourceFactory.createScene($rawfile("gltf/CubeWithFloor/glTF/AnimatedCube.glb"));
  } else {
    // 创建空场景
    return renderResourceFactory.createScene();
  }
}

这里有两个关键角色:

  • RenderContext:渲染上下文,你可以理解为 GPU 的"连接通道"。所有在同一个 RenderContext 下创建的场景,可以共享渲染资源(比如纹理、着色器)。Scene.getDefaultRenderContext() 拿到的是默认的全局上下文。
  • RenderResourceFactory:渲染资源工厂,通过它你可以创建场景(createScene)、着色器(createShader)、图像(createImage)、网格(createMesh)等各种资源。

createScene() 不传参数时,创建的是一个空场景——一个光秃秃的舞台,什么都没有。你需要自己往里面加东西。

Scene 对象有哪些能力?

Scene 对象提供了丰富的场景管理能力:

Scene 对象

节点管理

资源管理

渲染控制

getNodeByPath 按路径查找节点

cloneNode 克隆节点

importNode 从其他场景导入节点

importScene 导入整个场景

getResourceFactory 获取资源工厂

创建着色器/图像/网格/材质

renderFrame 按需渲染

loadPlugin 加载扩展插件

registerResourcePath 注册资源路径

destroy 销毁释放资源

加载或创建好 Scene 之后,来看看它能干什么。

属性一览

Scene 对象有几个重要的属性:

  • root:场景树的根节点,类型是 Node | null。整个 3D 场景是一棵树,所有东西(相机、灯光、模型)都是这棵树上的节点。
  • animations:动画数组,如果 glTF 模型里带有动画数据,会自动解析到这里。
  • environment:环境对象,控制场景的背景、光照等。
  • renderConfiguration:渲染配置,比如阴影贴图分辨率等。

通过路径查找节点

场景加载后,里面的节点可能很多,你需要一种方式来定位到特定的节点。getNodeByPath 就是干这个的:

import { Scene, Node } from '@kit.ArkGraphics3D';

function getNode(): void {
  // 加载场景资源,支持.gltf和.glb格式,路径和文件名可根据项目实际资源自定义
  let scene: Promise<Scene> = Scene.load($rawfile("gltf/CubeWithFloor/glTF/AnimatedCube.glb"));
  scene.then(async (result: Scene) => {
    if (result) {
         // 寻找指定路径的节点
        let node : Node | null = result.getNodeByPath("rootNode_");
    }
  });
}

路径用 / 分隔层级,比如 "rootNode_/Unnamed Node 1/AnimatedCube" 就是沿着节点树一层层往下找。你还可以传第二个参数 NodeType 来限定你期望找到的节点类型,如果类型不匹配就返回 null。

获取资源工厂

要往场景里添加新的元素(相机、灯光、材质等),你需要通过 SceneResourceFactory

import { SceneResourceFactory, Scene } from '@kit.ArkGraphics3D';

function getFactory(): void {
  // 加载场景资源,支持.gltf和.glb格式,路径和文件名可根据项目实际资源自定义
  let scene: Promise<Scene> = Scene.load($rawfile("gltf/CubeWithFloor/glTF/AnimatedCube.glb"));
  scene.then(async (result: Scene) => {
    if (result) {
         // 获得SceneResourceFactory对象
        let sceneFactory: SceneResourceFactory = result.getResourceFactory();
    }
  });
}

SceneResourceFactory 继承自 RenderResourceFactory,所以它不仅能创建渲染资源(着色器、图像、网格),还能创建场景元素(相机、灯光、节点、材质、环境等)。后面几篇文章会详细讲这些。

按需渲染

默认情况下,3D 场景是每帧都在渲染的。但有些场景你可能不需要这么高的刷新率——比如一个静态的产品展示页面,用户不操作的时候画面根本不变。这时候你可以用 renderFrame 来手动控制渲染:

import { Scene } from '@kit.ArkGraphics3D';

function RenderFrameTest() {
  // 加载场景资源,支持.gltf和.glb格式,路径和文件名可根据项目实际资源自定义
  Scene.load($rawfile("gltf/DamagedHelmet/glTF/DamagedHelmet.glb"))
    .then(async (result: Scene | undefined) => {
      if (!result) {
        return;
      }
      console.info("TEST RenderFrameTest");
      result.renderFrame({ alwaysRender: true });
  });
}

RenderParameters 里有个 alwaysRender 属性:设为 true 就是每帧都渲染(默认行为),设为 false 就是按需渲染——只在你调用 renderFrame() 的时候才画一帧。这对于省电和降低 GPU 负载很有帮助。

从其他场景导入节点

有时候你会遇到这样的需求:我有两个 glTF 模型,想把它们合并到一个场景里。ArkGraphics 3D 提供了 importNodeimportScene 两个方法来解决这个问题。

import { Scene } from '@kit.ArkGraphics3D';

function ImportNodeTest() {
  Scene.load().then(async (result: Scene | undefined) => {
    if (!result) {
      return;
    }
    // 加载场景资源,支持.gltf和.glb格式,路径和文件名可根据项目实际资源自定义
    Scene.load($rawfile("gltf/AnimatedCube/glTF/AnimatedCube.glb"))
      .then(async (extScene: Scene) => {
        let extNode = extScene.getNodeByPath("rootNode_/Unnamed Node 1/AnimatedCube");
        console.info("TEST ImportNodeTest");
        let node = result.importNode("scene", extNode, result.root);
        if (node) {
          node.position.x = 5;
        }
      });
  });
}

importNode 会把一个节点从源场景"搬"到目标场景里,搬完之后你就可以像操作普通节点一样修改它的位置、旋转、缩放了。如果你想把整个场景都导进来,可以用 importScene

import { Scene } from '@kit.ArkGraphics3D';

function ImportSceneTest() {
  Scene.load().then(async (result: Scene | undefined) => {
    if (!result) {
      return;
    }
    // 加载场景资源,支持.gltf和.glb格式,路径和文件名可根据项目实际资源自定义
    let content = await result.getResourceFactory().createScene($rawfile("gltf/DamagedHelmet/glTF/DamagedHelmet.glb"))
    console.info("TEST ImportSceneTest");
    result.importScene("helmet", content, null);
  });
}

这个功能在做虚拟展厅之类的应用时特别有用——你可以把每个展品做成独立的 glTF 文件,然后在运行时动态导入到主场景里。

克隆节点

如果你需要在场景里放多个一样的物体(比如一排相同的椅子),不需要加载多次同一个模型,直接克隆节点就行:

import { Scene, Node } from '@kit.ArkGraphics3D';

function CloneNode() {
  // 加载场景资源,支持.gltf和.glb格式,路径和文件名可根据项目实际资源自定义
  Scene.load($rawfile("gltf/CubeWithFloor/glTF/AnimatedCube.gltf"))
    .then(async (result: Scene) => {
      let node = result.getNodeByPath("rootNode_/Unnamed Node 1/AnimatedCube") as Node;
      let parent = result.root as Node;
      let name = "cloneNode_";
      let clone = result.cloneNode(node, parent, name);
      if (clone) {
        console.info("cloneNode success");
      } else {
        console.error("cloneNode failed");
      }
    });
}

cloneNode 有三个参数:要克隆的节点、克隆后挂到哪个父节点下、克隆节点的名字。注意克隆只能在同一个场景内进行,不支持跨场景克隆。

销毁场景

当你不再需要某个 3D 场景时(比如用户退出了查看页面),记得调用 destroy() 释放资源。3D 场景占用的内存和 GPU 资源都不少,不释放的话会越积越多:

import { Scene } from '@kit.ArkGraphics3D';

function destroy(): void {
  // 加载场景资源,支持.gltf和.glb格式,路径和文件名可根据项目实际资源自定义
  let scene: Promise<Scene> = Scene.load($rawfile("gltf/CubeWithFloor/glTF/AnimatedCube.glb"));
  scene.then(async (result: Scene) => {
    if (result) {
         // 销毁scene
        result.destroy();
    }
  });
}

渲染上下文的高级用法

除了默认的渲染上下文,你还可以用它来做一些高级操作。

加载插件

如果你需要扩展 3D 引擎的能力(比如自定义后处理效果),可以通过 loadPlugin 加载插件:

import { Scene, RenderContext } from '@kit.ArkGraphics3D';

function loadPlugin(): Promise<boolean> {
  const renderContext: RenderContext | null = Scene.getDefaultRenderContext();
  if (!renderContext) {
    console.error("RenderContext is null");
    return Promise.reject(new Error("RenderContext is null"));
  }
  return renderContext.loadPlugin("pluginName");
}

注册资源路径

当你使用自定义着色器时,着色器文件内部可能会引用其他资源文件(比如纹理图片)。这时候你需要告诉引擎这些资源在哪里:

import { Scene, RenderContext } from '@kit.ArkGraphics3D';

function registerResourcePath(): void {
  // 创建shader资源,路径和文件名可根据项目实际资源自定义
  Scene.load($rawfile("shaders/custom_shader/custom_material_sample.shader"))
    .then(() => {
      const renderContext: RenderContext | null = Scene.getDefaultRenderContext();
      if (!renderContext) {
        console.error("RenderContext is null");
        return false;
      }
      // 注册路径检索名"myproto"及其对应的资产路径目录"OhosRawFile://shaders/custom_shader/"
      // 当shader内部通过检索名引用关联文件,如路径为"myproto://textures/base.png",
      // 系统会将"myproto://"替换为"OhosRawFile://shaders/custom_shader/",
      // 最终从"OhosRawFile://shaders/custom_shader/textures/base.png"加载关联文件
      return renderContext.registerResourcePath("myproto", "OhosRawFile://shaders/custom_shader/");
    })
    .then(result => {
      if (result) {
        console.info("resource path registration success");
      } else {
        console.error("resource path registration failed");
      }
    });
}

这里 registerResourcePath 的第一个参数是"检索名"(就像一个协议前缀),第二个参数是实际的文件目录。当着色器内部写着 myproto://textures/base.png 时,引擎会自动把 myproto:// 替换成 OhosRawFile://shaders/custom_shader/,最终从正确的位置加载文件。

小结

Scene 是 ArkGraphics 3D 的入口和核心。掌握了 Scene 的创建和加载,你就搭好了 3D 开发的第一块积木。关键要点:

  • Scene.load() 是最常用的方式,直接加载 glTF/glb 模型
  • 通过 getDefaultRenderContext()getResourceFactory() 可以获取工厂来创建各种资源
  • 场景是一棵节点树,用 getNodeByPath 定位节点
  • 别忘了用完之后 destroy() 释放资源

下一篇文章我们来看看这棵节点树上的节点们——怎么移动、旋转、缩放它们,怎么组织父子关系。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐