想在 HarmonyOS 里展示 3D 扫描结果?用 SpatialReconKit 的 3DGS 渲染就对了

你有没有想过,用手机扫描一个房间或者一个物体之后,能直接在 APP 里以 3D 的方式展示出来,而且还能加上复古、漫画这种酷炫的滤镜效果?这就是 HarmonyOS SpatialReconKit 中 spatialRender 模块干的事情。

简单说,spatialRender 模块就是用来渲染 3DGS(3D Gaussian Splatting)数据的。3DGS 是一种比较新的 3D 场景表示技术,它用一堆高斯点来表示 3D 场景,渲染速度快、效果好。SpatialReconKit 把这套技术封装好了,你只需要几行代码就能把 3DGS 模型加载到场景里,再加上各种视觉特效。

3DGS 渲染的整体流程

使用 spatialRender 模块进行 3DGS 渲染的完整流程如下:

获取渲染上下文

加载 GSPlugin 插件

加载 3D 场景

加载 3DGS 模型

设置节点属性

需要特效?

创建视觉效果

复古效果 RetroEffect

漫画效果 ComicEffect

黑白点阵 ObraDinnEffect

颜色编辑 ColorEditingEffect

创建摄像机

挂载效果到摄像机

渲染输出

先把插件加载上

在做任何 3DGS 相关的操作之前,你必须先加载 GSPlugin 插件。这一步特别重要,不加载的话后面所有的接口调用都会出问题,文档里也明确说了"调用 GSPlugin 接口前,必须先加载对应的插件 ID,否则会出现未定义的行为"。

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

// 获取默认渲染上下文
let renderContext = Scene.getDefaultRenderContext();
if (renderContext != null) {
  // 加载空间重建插件GSPlugin
  renderContext.loadPlugin(spatialRender.GSPlugin.PLUGIN_ID);
}

这段代码做了两件事:第一步,通过 Scene.getDefaultRenderContext() 获取默认的渲染上下文(RenderContext),你可以把它理解为渲染的"画布管理器",所有的渲染操作都要通过它来进行;第二步,调用 renderContext.loadPlugin() 并传入 spatialRender.GSPlugin.PLUGIN_ID 这个常量来加载插件。PLUGIN_ID 的具体值是 1450021d-c57f-d9ff-7770-c24fb3f3321c,不过你不需要记这个,直接用常量就行。

为什么要单独加载插件?因为 3DGS 渲染是 SpatialReconKit 提供的扩展能力,不是 ArkGraphics 3D 的内置功能。通过插件机制,系统可以按需加载,避免不必要的资源开销。

加载一个 3DGS 模型到场景里

插件加载好之后,就可以把 3DGS 模型加载进来了。核心方法是 GSPlugin.loadGSNode(),它是一个异步方法,返回一个 Promise,resolve 之后得到一个 GSNode 对象。

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

// 获取默认的渲染上下文
let renderContext: RenderContext | null = Scene.getDefaultRenderContext();

if (renderContext != null) {
  renderContext.loadPlugin(spatialRender.GSPlugin.PLUGIN_ID);
  // 加载场景
  Scene.load().then(async (scene: Scene) => {
    let uri = "OhosRawFile://assets/gltf/model.glb";
    let offset = 0;
    
    // 通过GSPlugin.loadGSNode获取GSNode实例
    let gsNode: spatialRender.GSNode = await spatialRender.GSPlugin.loadGSNode(scene, { uri, offset }, scene.root);
    
    // 设置节点位置
    gsNode.position = { x: 3, y: 0, z: 0 };
    // 设置节点缩放
    gsNode.scale = { x: 1.5, y: 1.5, z: 1.5 };
    // 设置节点可见性
    gsNode.visible = true;
  });
}

我们一步步来看。首先通过 Scene.load() 加载一个 3D 场景,这一步也是异步的。场景加载完成后,在回调里调用 spatialRender.GSPlugin.loadGSNode(),它接收三个参数:

  • scene:当前的场景对象,告诉系统要把模型加载到哪个场景里
  • { uri, offset }:一个 GSImportSettings 对象,uri 是 3DGS 模型文件的路径,offset 是数据在文件中的偏移量(通常设为 0)
  • scene.root:父节点,表示要把这个模型挂载到场景的根节点下。如果不传这个参数,模型也会默认挂到根节点

loadGSNode 返回的 GSNode 继承自 Node(ArkGraphics 3D 的基础节点类),所以你可以像操作普通 3D 节点一样操作它——设置位置(position)、缩放(scale)、可见性(visible)等等。

GSImportSettings:告诉系统去哪里找模型

你注意到 loadGSNode 的第二个参数 { uri, offset } 了吗?它其实是一个 GSImportSettings 类型的对象,专门用来配置 3DGS 模型的加载参数。

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

// 获取默认的渲染上下文
let renderContext: RenderContext | null = Scene.getDefaultRenderContext();

if (renderContext != null) {
  renderContext.loadPlugin(spatialRender.GSPlugin.PLUGIN_ID);
  // 加载场景
  let scene = Scene.load().then(async (scene: Scene) => {
    // 设置3DGS模型文件的URI路径,根据实际情况修改
    let uri = "OhosRawFile://assets/gltf/doll.glb";
    // 偏移量参数(用于模型的位移调整)
    let offset = 0;
    // 配置导入参数
    let setting: spatialRender.GSImportSettings = { uri: uri, offset : offset};
    // 通过GSPlugin加载指定的3DGS节点,并添加到场景根节点下
    let gsNodeext: spatialRender.GSNode = await spatialRender.GSPlugin.loadGSNode(scene, setting, scene.root);
  });
}

GSImportSettings 有两个属性:

  • uri(必填):3DGS 模型文件的路径。传空字符串会导致加载失败,所以一定要确保路径正确
  • offset(可选,默认 0):数据在文件中的偏移量。一般情况下直接用 0 就行,除非你的模型数据嵌在某个大文件的特定位置

这里用了一个更清晰的写法,先创建 setting 对象再传入,效果和直接写 { uri, offset } 是一样的,只是可读性更好一些。

给 3DGS 场景加上复古效果

模型加载好了,光是原样展示可能不够酷。SpatialReconKit 内置了好几种视觉特效,先来看看复古效果(RetroEffect)。这个效果会模拟老式显像管电视的显示特征,带颜色抖动、下采样和屏幕曲率。

import { Scene, RenderContext, RenderingPipelineType, Effect } from '@kit.ArkGraphics3D';
import { spatialRender } from '@kit.SpatialReconKit';

// 获取默认渲染上下文
let renderContext: RenderContext | null = Scene.getDefaultRenderContext();

if (renderContext != null) {
  // 加载空间重建插件GSPlugin
  renderContext.loadPlugin(spatialRender.GSPlugin.PLUGIN_ID);
  // 加载场景
  Scene.load().then(async (scene: Scene) => {
    // 获取场景的资源工厂
    let rf = scene.getResourceFactory();
    // 创建复古效果实例(使用GSPlugin预定义的效果ID)
    let effect : Effect =
      await rf.createEffect({ effectId: spatialRender.GSPlugin.RETRO_EFFECT_ID });
    // 获取复古效果参数(颜色数量)的当前值
    let colNum = effect.getPropertyValue(spatialRender.RetroEffectParams.COLOR_NUM);
    // 设置复古效果参数(颜色数量)为4
    let res = effect.setPropertyValue(spatialRender.RetroEffectParams.COLOR_NUM, 4);
    // 创建摄像机
    let camera = await rf.createCamera({ name: "gsCam", path: "//gsCam" }, { renderingPipeline: RenderingPipelineType.FORWARD });
    // 将效果添加到摄像机的效果链中
    camera.effects.append(effect)
  });
}

这段代码做了好几件事,我们拆开来看:

  1. 获取资源工厂scene.getResourceFactory() 返回一个资源工厂对象,所有的效果、摄像机等资源都通过它来创建
  2. 创建效果实例:调用 rf.createEffect() 并传入 spatialRender.GSPlugin.RETRO_EFFECT_ID 作为效果 ID。这个 ID 是预定义的,你不需要记具体的值
  3. 调整效果参数:通过 getPropertyValue()setPropertyValue() 可以读取和修改效果的属性。这里把颜色数量(COLOR_NUM)设为 4,值越小复古风格越重
  4. 创建摄像机:摄像机决定了你从什么角度观察场景。这里创建了一个前向渲染管线的摄像机
  5. 把效果挂到摄像机上camera.effects.append(effect) 将复古效果添加到摄像机的效果链中,这样通过这个摄像机看到的画面就会带有复古效果

复古效果有 4 个可调参数:

  • colorNum:颜色抖动用的颜色数量,值越大图像质量越高,值越小复古感越强,默认 8
  • pixelSize:下采样程度,越大颗粒感越重,默认 4(设为 1 就不下采样)
  • blendEnabled:是否把处理后的图和原图融合,设为 true 可以保持亮度和色彩,默认 true
  • curve:显像管屏幕的曲率,值越大弯曲越明显,默认 0.25

漫画效果:让你的 3D 场景变成漫画风

除了复古效果,还有漫画效果(ComicEffect)。它会检测图像中的轮廓线,然后用指定颜色的线条画出来,看起来就像漫画一样。

import { Scene, RenderContext, RenderingPipelineType, Effect } from '@kit.ArkGraphics3D';
import { spatialRender } from '@kit.SpatialReconKit';

// 获取默认渲染上下文
let renderContext: RenderContext | null = Scene.getDefaultRenderContext();

if (renderContext != null) {
  // 加载空间重建插件GSPlugin
  renderContext.loadPlugin(spatialRender.GSPlugin.PLUGIN_ID);
  // 加载场景
  Scene.load().then(async (scene: Scene) => {
    // 获取场景的资源工厂
    let rf = scene.getResourceFactory();
    // 创建漫画效果实例(使用GSPlugin预定义的漫画效果ID)
    let effect : Effect =
      await rf.createEffect({ effectId: spatialRender.GSPlugin.COMIC_EFFECT_ID });
    // 获取漫画效果参数(线条阈值)的当前值
    let threshold = effect.getPropertyValue(spatialRender.ComicEffectParams.LINE_THRESHOLD);
    // 设置漫画效果参数(线条阈值)为0.5
    let res = effect.setPropertyValue(spatialRender.ComicEffectParams.LINE_THRESHOLD, 0.5);
    // 创建摄像机
    let camera = await rf.createCamera({ name: "gsCam", path: "//gsCam" }, { renderingPipeline: RenderingPipelineType.FORWARD });
    // 将漫画效果添加到摄像机的效果链中
    camera.effects.append(effect)
  });
}

和复古效果的套路一模一样,只是效果 ID 换成了 COMIC_EFFECT_ID,参数换成了 ComicEffectParams

漫画效果有两个参数:

  • lineThreshold:判定像素为轮廓线的阈值。图像梯度(你可以理解为相邻像素之间的明暗差异)大于这个值的像素就会被判定为轮廓线。值越小,画出来的线条越多;值越大,只保留最明显的轮廓。取值范围 [0, 1],默认 0.2
  • lineColor:轮廓线的颜色,类型是 Color

如果你想要更强的漫画感,可以把 lineThreshold 调大一点,比如 0.5,这样只有最明显的轮廓线会被画出来,画面更简洁。

黑白点阵效果:ObraDinnEffect

这个效果的名字来源于游戏《Return of the Obra Dinn》,它会把画面处理成黑白点阵风格,很有艺术感。

import { Scene, RenderContext, RenderingPipelineType, Effect } from '@kit.ArkGraphics3D';
import { spatialRender } from '@kit.SpatialReconKit';

// 获取默认渲染上下文
let renderContext: RenderContext | null = Scene.getDefaultRenderContext();

if (renderContext != null) {
  // 加载空间重建插件GSPlugin
  renderContext.loadPlugin(spatialRender.GSPlugin.PLUGIN_ID);
  // 加载场景
  Scene.load().then(async (scene: Scene) => {
    // 获取场景的资源工厂
    let rf = scene.getResourceFactory();
    // 创建黑白bit效果实例(使用GSPlugin预定义的效果ID)
    let effect : Effect =
      await rf.createEffect({ effectId: spatialRender.GSPlugin.OBRA_DINN_EFFECT_ID });
    // 获取黑白bit效果参数(噪声强度)的当前值
    let noiseStrength = effect.getPropertyValue(spatialRender.ObraDinnEffectParams.NOISE_STRENGTH);
    // 设置黑白bit效果参数(噪声强度)为0.5
    let res = effect.setPropertyValue(spatialRender.ObraDinnEffectParams.NOISE_STRENGTH, 0.5);
    // 创建摄像机
    let camera = await rf.createCamera({ name: "gsCam", path: "//gsCam" }, { renderingPipeline: RenderingPipelineType.FORWARD });
    // 将黑白bit效果添加到摄像机的效果链中
    camera.effects.append(effect)
  });
}

ObraDinnEffect 有 4 个参数:

  • noiseStrength:噪声强度,用来决定哪些像素参与颜色抖动。加大噪声可以让边缘更模糊,起到平滑效果。取值范围 [0, 1],默认 0.3
  • threshold:前景/后景的分界阈值。值越高,画面越偏向后景颜色;值越低,画面越偏向前景颜色。取值范围 [0, 1],默认 0.4
  • foregroundColor:前景颜色
  • backgroundColor:后景颜色

你可以通过调整前景色和后景色来实现不同的配色方案,不一定是纯黑白,也可以是深蓝配浅黄这种。

颜色编辑效果:精细调色

如果你不想用那种风格化很强的效果,只是想微调一下画面的色彩,ColorEditingEffect 就是你需要的。它提供了曝光、对比度、色温、色调、饱和度、自然饱和度这些专业调色参数。

import { Scene, RenderContext, RenderingPipelineType, Effect } from '@kit.ArkGraphics3D';
import { spatialRender } from '@kit.SpatialReconKit';

// 获取默认渲染上下文
let renderContext: RenderContext | null = Scene.getDefaultRenderContext();

if (renderContext != null) {
  // 加载空间重建插件GSPlugin
  renderContext.loadPlugin(spatialRender.GSPlugin.PLUGIN_ID);
  // 加载场景
  Scene.load().then(async (scene: Scene) => {
    // 获取场景的资源工厂
    let rf = scene.getResourceFactory();
    // 创建颜色编辑效果实例(使用GSPlugin预定义的效果ID)
    let effect : Effect =
      await rf.createEffect({ effectId: spatialRender.GSPlugin.COLOR_EDITING_EFFECT_ID });
    // 获取颜色编辑效果参数(曝光度)的当前值
    let exposure = effect.getPropertyValue(spatialRender.ColorEditingEffectParams.EXPOSURE);
    // 设置颜色编辑效果参数(曝光度)为0.5
    let res = effect.setPropertyValue(spatialRender.ColorEditingEffectParams.EXPOSURE, 0.5);
    // 创建摄像机
    let camera = await rf.createCamera({ name: "gsCam", path: "//gsCam" }, { renderingPipeline: RenderingPipelineType.FORWARD });
    // 将颜色编辑效果添加到摄像机的效果链中
    camera.effects.append(effect)
  });
}

ColorEditingEffect 的参数挺丰富的:

  • exposure:曝光度,推荐范围 [-5, 5],默认 0.0
  • contrast:对比度,推荐范围 [0, 2],默认 1.0
  • temperature:色温,负值偏冷(蓝),正值偏暖(黄),推荐范围 [-2, 2],默认 0.0
  • tint:色调,推荐范围 [-1, 1],默认 0.0
  • saturation:饱和度,推荐范围 [0, 2],默认 1.0
  • vibrance:自然饱和度,推荐范围 [-1, 1],默认 0.0

和 Photoshop 里的调色面板差不多,你可以根据需要精细调整。

用强类型获取效果对象

前面的例子里,我们创建效果的时候用的是 Effect 类型。但如果你需要直接访问效果的属性(而不是通过 setPropertyValue),可以用 as 关键字把效果转成具体的类型,比如 RetroEffectComicEffectObraDinnEffectColorEditingEffect

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

// 获取默认渲染上下文
let renderContext: RenderContext | null = Scene.getDefaultRenderContext();

if (renderContext != null) {
  // 加载空间重建插件GSPlugin
  renderContext.loadPlugin(spatialRender.GSPlugin.PLUGIN_ID);
  // 加载场景
  Scene.load().then(async (scene: Scene) => {
    // 获取场景的资源工厂
    let rf = scene.getResourceFactory();
    // 创建复古效果实例(类型为RetroEffect)
    let effect : spatialRender.RetroEffect =
      await rf.createEffect({ effectId: spatialRender.GSPlugin.RETRO_EFFECT_ID }) as spatialRender.RetroEffect;
    // 创建摄像机
    let camera = await rf.createCamera({ name: "gsCam", path: "//gsCam" }, { renderingPipeline: RenderingPipelineType.FORWARD });
    // 将效果添加到摄像机的效果链中
    camera.effects.append(effect)
  });
}

关键区别在 as spatialRender.RetroEffect 这个类型断言。转成强类型之后,你就可以直接用 effect.colorNumeffect.pixelSize 这样的属性访问方式,不用再通过 getPropertyValue / setPropertyValue 来读写了。代码更简洁,也有更好的类型提示。

特效参数调节指南

不同特效有不同的可调参数,下面是各特效的参数对比:

视觉特效

复古效果

漫画效果

黑白点阵

颜色编辑

colorNum 颜色数量

pixelSize 下采样

blendEnabled 融合

curve 曲率

lineThreshold 线条阈值

lineColor 线条颜色

noiseStrength 噪声强度

threshold 前后景阈值

foregroundColor 前景色

backgroundColor 后景色

exposure 曝光度

contrast 对比度

temperature 色温

saturation 饱和度

把这些串起来

回顾一下完整的流程:加载插件 → 加载场景 → 加载 3DGS 模型 → 创建效果 → 把效果挂到摄像机。每一步都不能少,顺序也不能乱。

如果你要做一个空间扫描结果展示的 APP,基本流程就是:用户扫描完一个空间后,系统生成 3DGS 模型文件,你的 APP 加载这个文件并渲染出来,再根据用户的选择加上不同的视觉效果。spatialRender 模块把这些都封装好了,你不需要关心底层的渲染细节,只需要调用对应的 API 就行。

Logo

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

更多推荐