HarmonyOS 6 ArkGraphics 3D 精讲:几何体、材质与纹理
-
- 一、理解ArkGraphics 3D 资源链路,模型、材质、贴图的关系。
- 二、几何体和 Mesh:形状是怎么被造出来的
-
- 在ArkGraphics 3D里创建几何体
- 什么时候适合运行时创建?
- 三、材质:同一个形状,为什么看起来完全不同
-
- 材质:给几何体穿上“外衣”
- 封装一个统一的创建方法
- 四、纹理与Image:图片到底是怎么贴到模型上的
-
- 加载图片资源
- UV:模型表面的坐标系统
- 纹理的成本
- 五、glTF:资源进入工程之前,先把规范说清楚
-
- glTF 里有什么
- 六、案例实操:几何、材质、纹理如何协同工作
- 七、常见坑点与本篇小结
做 3D 商品展示的时候,最常见的一个需求是“换皮肤”。
比如一个售卖T恤的3D可视化网站,用户可以自由选择T恤的配色,更换logo,这里面涉及的是几何体、材质、纹理、图片资源、Mesh 和场景节点之间的关系。
很多开发者在实际开发中可能会遇到类似下面这种材质、贴图的问题:
- 模型能显示,但换材质没反应。
- 图片路径没错,但贴图没有正常显示。
- 同一张贴图换到不同节点,效果被拉伸。
- glTF 在编辑器里正常,放到工程里就变成灰白色。
这一篇我们就讲这件事:几何体、材质、纹理、Geometry之间的关系。
我们计划做一个小案例,可以切换 Cube / Sphere,切换 PBR / Unlit,切换无贴图、立方体贴图、头盔 albedo 贴图,也可以切换颜色预设,来观察下面的资源链路:
Image -> Material -> Mesh -> Geometry -> Scene
一、理解ArkGraphics 3D 资源链路,模型、材质、贴图的关系。

在 2D 页面里,一张图片通常就是一个完整资源。路径对了,图片就显示。
3D 场景不太一样。
屏幕上有一个球,其实背后至少有几层东西在协作:
| 顶点 | 决定有哪些点 | 模型的基础数据 |
| 三角面 | 决定点怎么连成面 | 模型轮廓和表面 |
| 法线 | 决定面朝哪里 | 影响受光效果 |
| UV | 决定贴图怎么铺 | 影响纹理方向和拉伸 |
| Mesh | 保存网格资源 | 可以被 Geometry 使用 |
| Material | 描述表面 | 颜色、贴图、金属度、粗糙度 |
| Geometry | 场景里的可见节点 | 有位置、旋转、缩放 |
所以“模型渲染”不是一步完成的,它更像下面这条链路:
几何描述 -> Mesh 资源 -> Geometry 节点 -> Material 材质 -> Scene 场景
如果还有贴图,细分下大概是这样的:
Image -> Material.baseColor.image -> Geometry.mesh.materialOverride
在 ArkGraphics 3D 里,这个过程非常直观。比如我们创建一个立方体:
let cube = new CubeGeometry();
cube.size = { x: 1.25, y: 1.25, z: 1.25 };
let mesh = await rf.createMesh({ name: 'Article05CubeMesh' }, cube);
let geometry = await rf.createGeometry({ name: 'Article05Cube' }, mesh);
geometry.mesh.materialOverride = material;
root.children.append(geometry);
这几行代码里, CubeGeometry、Mesh 和 Geometry容易混淆。
CubeGeometry 只是一个几何描述,告诉引擎“我要一个多大的立方体”。
Mesh 是资源,保存网格数据。它也不是直接挂在场景树里的对象。
Geometry 才是可见节点。它能设置 position、rotation、scale,也能挂到 root.children 下面。
用最直白的方式来比喻,可以把这三者的关系理解成 “房子的骨架、皮肤和实物”:
Mesh(网格)—— 形状数据
- 通俗说: 就像是3D模型的“骨架”或“轮廓图”。
- 理解: 它决定了这个东西是方的、圆的、还是凹凸不平的。它只记录“位置”和“形状”,比如一个杯子 Mesh,就是杯子外形的无数个小三角片拼成的网。它本身是看不见的“空壳”。
Material(材质)—— 表面表现
- 通俗说: 就像是物体的“皮肤”或“衣服”。
- 理解: 它决定了这个东西看起来是什么感觉。比如同一个球体(Mesh),加上红塑料材质就是皮球,加上不锈钢材质就是钢珠,加上玻璃材质就是水晶球。它管颜色、反不反光、粗糙还是光滑、有没有纹理。
Geometry(几何体)—— 场景里的可见对象
- 通俗说: 就像摆在你面前的那个“实实在在的成品”。
- 理解: 它是把上面的**Mesh(形状)和Material(材质)合在一起,放到场景里的一个具体东西。比如“桌上的那个红色茶杯”。Geometry 才是你最终在屏幕上能看见、能摸到(交互)**的那个物体。
二、几何体和 Mesh:形状是怎么被造出来的
先说几何体
在实时渲染里,再复杂的模型最终都会被拆成三角形。你看到的那个光滑球体,本质上也是一堆三角面拼出来的。三角面越多,球就越圆;少了,就是那种低多边形的风格。
每个顶点不止记录位置,通常还带着这些信息:
- position:坐标
- normal:法线,控制光照怎么算
- uv:贴图坐标
- color:顶点颜色
- tangent:切线,做法线贴图时用
- 骨骼权重:角色动画里会用到
所以说,几何体不只是个外轮廓,它还存着材质和贴图需要的数据。
举个例子:贴图为什么对不准?很多时候不是图片的问题,是UV没展好。光照看起来别扭?多半不是灯光的问题,是法线算错了。
在ArkGraphics 3D里创建几何体

实验台那段代码,就是在球体和立方体之间切换:
if (this.geometryMode === 'Sphere') {
let sphere = new SphereGeometry();
sphere.radius = 0.72;
sphere.segmentCount = 36;
let sphereMesh = await rf.createMesh({ name: this.nextResourceName('Article05SphereMesh') }, sphere);
previewGeometry = await rf.createGeometry({ name: this.nextResourceName('Article05Sphere') }, sphereMesh);
} else {
let cube = new CubeGeometry();
cube.size = { x: 1.25, y: 1.25, z: 1.25 };
let cubeMesh = await rf.createMesh({ name: this.nextResourceName('Article05CubeMesh') }, cube);
previewGeometry = await rf.createGeometry({ name: this.nextResourceName('Article05Cube') }, cubeMesh);
}
这段代码其实很适合理解 createMesh 和 createGeometry 的区别:
- createMesh:把几何描述转成网格资源
- createGeometry:把网格塞进一个可见节点
let cubeMesh = await rf.createMesh({ name: 'Article05CubeMesh' }, cube);
let geometry = await rf.createGeometry({ name: 'Article05Cube' }, cubeMesh);
什么时候适合运行时创建?
如果你要做辅助线、调试点、标记面板、或者简单的图形,这种运行时生成的方式就很方便。比如实验台里的XYZ坐标轴,其实就是三个被拉长的立方体:
async createBox(rf: SceneResourceFactory, parent: Node, name: string, size: Vec3, position: Vec3,
material: Material): Promise<Geometry> {
let box = new CubeGeometry();
box.size = size;
let mesh = await rf.createMesh({ name: `${name}Mesh` }, box);
let node: Geometry = await rf.createGeometry({ name: name }, mesh);
node.position = position;
node.mesh.materialOverride = material;
parent.children.append(node);
return node;
}
运行时创建的几何体,适合简单、规则、能用参数描述的东西。复杂的模型,还是得交给Blender、Maya这类工具去建模,然后导出glTF。
比如这些就不适合手写代码来搓:
- 设备外壳
- 汽车内饰
- 角色模型
- 商品细节
- 带骨骼的动画模型
这些东西需要正确的拓扑、UV、法线、材质槽和贴图关系。代码的任务是加载和控制,不是替代美术的生产流程。
两类来源,各司其职
| 运行时创建 | 坐标轴、调试线、标记点、简单球体或立方体 |
| glTF导入 | 商品模型、角色、设备、复杂场景、动画模型 |
这样分清楚,用起来心里就有数了。
三、材质:同一个形状,为什么看起来完全不同

材质:给几何体穿上“外衣”
简单来说,几何体决定了“这是什么形状”,材质决定了“这看起来像什么”。
同一个球体,可以看起来像塑料球,也可以像金属球,还能变成一个不受光照影响的UI按钮。形状没变,但视觉感受完全不同——差别全在材质上。
在ArkGraphics 3D的这篇案例里,我们主要用了两类材质:
| MaterialType.METALLIC_ROUGHNESS | PBR金属粗糙度材质,受光照影响 | 商品、设备、写实模型 |
| MaterialType.UNLIT | 不受光照影响,颜色稳定 | 坐标轴、标签、辅助线、UI类元素 |
先看Unlit材质
let material = await rf.createMaterial({ name: name }, MaterialType.UNLIT) as UnlitMaterial;
material.baseColor = {
image: null,
factor: { x: r, y: g, z: b, w: 1 }
};
Unlit特别适合做辅助元素。实验台里的坐标轴就是这么处理的——原因很简单:坐标轴是给人看的参照物,不应该因为灯光角度变化就变暗。
如果X轴时亮时暗,读者反而会怀疑坐标方向是不是出了问题。
再看PBR材质
let material: MetallicRoughnessMaterial =
await rf.createMaterial(
{ name: this.nextResourceName(`${name}Pbr`) },
MaterialType.METALLIC_ROUGHNESS
) as MetallicRoughnessMaterial;
material.baseColor = {
image: image,
factor: { x: color.r, y: color.g, z: color.b, w: 1 }
};
PBR更适合表现真实物体。它会综合光源、环境光、表面粗糙度和金属度来决定最终效果。一个电商商品、一个车载模型、或者一个设备可视化对象,一般都不应该只用Unlit来应付。
封装一个统一的创建方法
实验台里封装了这样一个createMaterial:
async createMaterial(rf: SceneResourceFactory, name: string, mode: string, textureMode: string): Promise<Material> {
const color: ColorPreset = this.getColorPreset();
const image: Image | null = textureMode === 'None' ? null : this.getTextureImage();
if (mode === 'Unlit') {
let material: UnlitMaterial =
await rf.createMaterial({ name: this.nextResourceName(`${name}Unlit`) }, MaterialType.UNLIT) as UnlitMaterial;
material.baseColor = {
image: image,
factor: { x: color.r, y: color.g, z: color.b, w: 1 }
};
return material;
}
let material: MetallicRoughnessMaterial =
await rf.createMaterial({ name: this.nextResourceName(`${name}Pbr`) },
MaterialType.METALLIC_ROUGHNESS) as MetallicRoughnessMaterial;
material.baseColor = {
image: image,
factor: { x: color.r, y: color.g, z: color.b, w: 1 }
};
return material;
}
这段代码的核心不在于“封装了一个函数”,而是把材质的几个关键要素放在了一起:
- 材质类型:PBR还是Unlit
- 图片资源:有贴图还是没有
- 颜色因子:baseColor.factor
- 最终挂载:geometry.mesh.materialOverride
运行时换材质
用户点击按钮时,不需要重新加载整个场景,只需要创建一个新材质,然后替换当前Geometry的材质:
this.previewGeometry.mesh.materialOverride = material;
这个API值得留意。
materialOverride可以理解成“运行时给几何体换一层皮肤”。在商品展示里,切换颜色、更换贴图、选中高亮,很多交互都可以从这里下手。
如果每次换颜色都重新调用Scene.load(),用户体验会非常糟糕——页面闪烁、资源重复加载、内存也越来越难控制。用materialOverride就轻松得多。
四、纹理与Image:图片到底是怎么贴到模型上的

说到贴图,很多人第一反应就是“图片绑定到模型上”。一张图片要真正变成模型表面的纹理,至少需要经过三步:
createImage 创建图片资源
-> material.baseColor.image 绑定到材质
-> 模型通过 UV 把图片映射到表面
加载图片资源
本篇的 demo 里加载了两张图片:
this.cubeImage = await rf.createImage({
name: 'Article05CubeImage',
uri: $rawfile('gltf/Cube/glTF/Cube_BaseColor.png')
});
this.helmetImage = await rf.createImage({
name: 'Article05HelmetAlbedo',
uri: $rawfile('gltf/DamagedHelmet/glTF/Default_albedo.jpg')
});
然后根据按钮状态决定用哪一张:
getTextureImage(): Image | null {
if (this.textureMode === 'CubeTexture') {
return this.cubeImage;
}
if (this.textureMode === 'HelmetAlbedo') {
return this.helmetImage;
}
return null;
}
真正把图片挂到材质上的是这个地方:
material.baseColor = {
image: image,
factor: { x: color.r, y: color.g, z: color.b, w: 1 }
};
逻辑很简单:image 为 null 时,只显示 factor 对应的颜色;image 有值时,图片会作为 baseColor 贴图参与显示。
UV:模型表面的坐标系统
这里要提一个很实际的问题:贴图能不能正常显示,不只看图片有没有加载成功,还要看模型有没有合适的 UV。
UV 可以理解成模型表面的二维坐标。GPU 要知道图片的哪一块对应模型的哪一个三角形,全靠 UV 来告诉它。
所以遇到贴图问题的时候,可以先对照一下这个表:
| 贴图完全没有出现 | 没有绑定 baseColor.image,或 Image 创建失败 |
| 贴图出现但方向不对 | UV 方向或导出设置有问题 |
| 贴图被拉伸 | UV 展开不合理 |
| 球体贴图有接缝 | 球面 UV 天然容易出现接缝 |
| 模型发黑 | 法线、光照、材质参数或颜色空间问题 |
这也是为什么同一张头盔贴图贴到球体上,效果不一定好看。那张图片本来就是为 DamagedHelmet 模型的 UV 展开准备的。换到球体或立方体上,只能说明“材质绑定成功了”,但不能说明它适合做这个几何体的贴图。
同一张图片,多处复用
实验台里还做了一个贴图板:
let boardMaterial: Material = await this.createMaterial(rf, 'TextureBoardMaterial', 'Unlit', 'CubeTexture');
let boardGeometry = new CubeGeometry();
boardGeometry.size = { x: 1.35, y: 0.05, z: 1.35 };
let boardMesh = await rf.createMesh({ name: 'TextureBoardMesh' }, boardGeometry);
let textureBoard: Geometry = await rf.createGeometry({ name: 'TextureBoard' }, boardMesh);
textureBoard.mesh.materialOverride = boardMaterial;
root.children.append(textureBoard);
这个板子的作用是帮我们观察图片资源。它说明了一个道理:同一张 Image 可以喂给不同的 Material,也可以被不同的 Geometry 共用。
这在真实业务里很常见。比如一个鞋子模型,鞋面、鞋带、鞋底可能是不同的 Geometry。你可以只换鞋面的贴图,不动鞋底;也可以让多个零件共用一张贴图集。
纹理的成本
纹理还有一个绕不开的问题:它吃内存。
一张 2048 x 2048 的 RGBA 图片,未压缩时大约 16MB。一个模型如果带十几张大图,加载速度、内存、功耗都会受影响。移动端尤其要克制。
几点实用建议:
- 不要所有贴图都上 2K 或 4K
- 同一张图不要重复创建资源
- 不需要透明就别用透明图
- 能用贴图集就尽量减少散图
- 商品展示优先关注用户能看见的细节,背面隐藏区域没必要做高精度贴图
性能优化后面会有专门的文章来讲,这里先记住一句话:纹理不是免费的,图片越大,渲染链路越重。
五、glTF:资源进入工程之前,先把规范说清楚
运行时创建 Cube 和 Sphere 用来教学很合适,但真实项目不可能只有基本几何体。
商品、设备、车模、角色、场景,绝大多数都来自外部建模工具。对 ArkGraphics 3D 来说,glTF 是一条非常重要的资源入口。
glTF 里有什么
一个 glTF 文件通常包含这些内容:
| Scene | 场景入口 |
| Node | 节点层级 |
| Mesh | 网格 |
| Material | 材质 |
| Texture | 纹理引用 |
| Image | 图片 |
| Animation | 动画 |
| Skin | 骨骼蒙皮 |
加载方式
加载一个 glTF 场景,代码很简单:
Scene.load($rawfile('gltf/DamagedHelmet/glTF/DamagedHelmet.gltf'))
.then((scene: Scene) => {
this.scene = scene;
});
但这篇的 demo 并没有直接把 DamagedHelmet 整个加载进来,而是单独拿走了它的 albedo 图片:
this.helmetImage = await rf.createImage({
name: 'Article05HelmetAlbedo',
uri: $rawfile('gltf/DamagedHelmet/glTF/Default_albedo.jpg')
});
这样做的目的是把两个概念区分清楚:
- Scene.load:整体加载场景、节点、网格、材质、贴图关系
- createImage:单独创建图片资源,再手动绑定到材质
真实项目里,两种方式都会用到。
举个例子:一个电商 3D 商品页,美术交付的是完整 glTF。你可以先整体加载,让默认材质和贴图正常显示;用户选择不同颜色时,再找到对应部件,动态替换它的材质或贴图。
推荐的工作流
比较靠谱的流程大致是这样的:
Blender / Maya 建模
-> 展 UV
-> 制作贴图
-> 配置材质
-> 导出 glTF / glb
-> 放入 rawfile
-> Scene.load 加载
-> 代码查找节点
-> 替换材质 / 播放动画 / 绑定交互
开发和美术的协作规范
这个流程里最怕的,是开发和美术之间没有约定好规范。
比如美术说“模型好了”,开发一加载发现:
- 模型太大或太小,根本看不见
- 材质全丢了
- 贴图路径还是美术电脑上的绝对路径
- 节点命名全是 Cube.001、Sphere.002
- 想单独换颜色的部件和其他部件合并成了一个网格
这些问题不是 ArkGraphics 3D 独有的,所有实时 3D 项目都会遇到。
建议提前约定好以下几件事:
| 坐标轴 | 统一模型朝向,比如 Y 轴向上 |
| 单位 | 统一尺寸单位,比如米或厘米 |
| 节点命名 | 需要交互的部件用可读的英文命名 |
| 材质命名 | 需要动态替换的材质单独命名 |
| 贴图尺寸 | 不要无脑上 4K |
| 贴图路径 | 确保随 glTF 一起进入工程,不要用绝对路径 |
| 部件拆分 | 需要单独替换材质的部件不要合并 |
很多所谓的“代码问题”,查到最后发现其实是资源交付的问题。也是为了给后面讲工具链和性能打一个基础。
六、案例实操:几何、材质、纹理如何协同工作
回到 demo 本身。
初始化场景
我们从空场景开始:
Scene.load()
.then(async (result: Scene) => {
this.scene = result;
this.scene.environment.backgroundType = EnvironmentBackgroundType.BACKGROUND_NONE;
this.scene.environment.indirectDiffuseFactor = this.makeVec4(0.20, 0.20, 0.20, 1);
this.scene.environment.indirectSpecularFactor = this.makeVec4(0.12, 0.12, 0.12, 1);
let rf: SceneResourceFactory = this.scene.getResourceFactory();
this.resourceFactory = rf;
await this.createCameraAndLight(rf);
await this.loadImages(rf);
await this.createSceneContent(rf);
await this.rebuildPreview();
this.sceneOpt = { scene: this.scene, modelType: ModelType.SURFACE } as SceneOptions;
});
这里没有直接加载 glTF,因为我们要手动搭建一条完整的资源链路。
场景树结构的延续
预览模型挂在 previewRoot 下面:
this.previewRoot = await rf.createNode({ name: 'Article05PreviewRoot' });
root.children.append(this.previewRoot);
自动旋转时,旋转的是 previewRoot:
this.animator.onFrame = () => {
if (!this.running || !this.previewRoot) {
return;
}
this.rotationDegree = (this.rotationDegree + 0.8) % 360;
this.previewRoot.rotation = this.makeYRotation(this.rotationDegree);
};
这延续了第四篇的场景树思路:父节点负责稳定的控制逻辑,子节点可以随时替换。立方体换成球体,旋转逻辑完全不用动。
几何体切换
几何体的切换通过 rebuildPreview 实现:
let oldPreviewGeometry: Geometry | null = this.previewGeometry;
let material: Material = await this.createMaterial(rf, 'Article05PreviewMaterial', this.materialMode, this.textureMode);
let previewGeometry: Geometry;
if (this.geometryMode === 'Sphere') {
let sphere = new SphereGeometry();
sphere.radius = 0.72;
sphere.segmentCount = 36;
let sphereMesh = await rf.createMesh({ name: this.nextResourceName('Article05SphereMesh') }, sphere);
previewGeometry = await rf.createGeometry({ name: this.nextResourceName('Article05Sphere') }, sphereMesh);
} else {
let cube = new CubeGeometry();
cube.size = { x: 1.25, y: 1.25, z: 1.25 };
let cubeMesh = await rf.createMesh({ name: this.nextResourceName('Article05CubeMesh') }, cube);
previewGeometry = await rf.createGeometry({ name: this.nextResourceName('Article05Cube') }, cubeMesh);
}
this.previewGeometry = previewGeometry;
previewGeometry.position = { x: 0.45, y: 0.05, z: 0 };
previewGeometry.mesh.materialOverride = material;
previewRoot.children.append(previewGeometry);
if (oldPreviewGeometry) {
previewRoot.children.remove(oldPreviewGeometry);
}
这里有一个值得注意的细节:先添加新节点,再删除旧节点。
如果反过来,先删旧节点再异步创建新节点,中间会出现短暂的空窗期,画面可能会闪一下。实验台里按钮点得频繁,这种体验问题会被放大。
材质切换
材质切换则不需要重建几何体:
async refreshPreviewMaterial(): Promise<void> {
let rf: SceneResourceFactory | null = this.resourceFactory;
if (!rf || !this.previewGeometry) {
return;
}
let material: Material = await this.createMaterial(rf, 'Article05PreviewMaterial', this.materialMode, this.textureMode);
this.previewGeometry.mesh.materialOverride = material;
}
按钮逻辑的分工
按钮的处理逻辑也按照这个原则分开:
setChoice(group: string, value: string): void {
if (this.isChoiceSelected(group, value)) {
return;
}
if (group === 'geometry') {
this.geometryMode = value;
this.updateResourceText();
this.rebuildPreviewLater();
return;
}
if (group === 'material') {
this.materialMode = value;
} else {
this.textureMode = value;
}
this.updateResourceText();
this.refreshPreviewMaterialLater();
}
这段代码反映的不是 UI 技巧,而是资源操作的判断原则:
| 几何体变了 | 重建 Geometry |
| 材质类型变了 | 替换 Material |
| 贴图变了 | 替换 Material |
| 颜色变了 | 替换 Material |
状态展示
实验台还会把当前状态直接打印出来:
this.resourceText =
`Geometry: ${this.geometryMode}Geometry -> createMesh -> createGeometry\\n` +
`Material: MaterialType.${this.materialMode === 'Unlit' ? 'UNLIT' : 'METALLIC_ROUGHNESS'}\\n` +
`Texture: ${textureText}\\n` +
`Override: geometry.mesh.materialOverride`;
举个例子,点击球体按钮,你应该看到:
Geometry: SphereGeometry -> createMesh -> createGeometry
点击 Unlit 材质:
Material: MaterialType.UNLIT
点击头盔贴图:
Texture: baseColor.image = HelmetAlbedo
通过这种方式,你可以清楚地看到每一步操作对应的是哪一层资源的变化,而不是被一个黑盒框架包在里头。
七、常见坑点与本篇小结
这一篇聊的是资源体系,最后把几个容易踩的坑汇总一下。
Mesh 本质上是个资源,Geometry 才是能挂到场景树里的可见节点。两者的关系要分清楚:
let mesh = await rf.createMesh({ name: 'mesh' }, cube);
let node = await rf.createGeometry({ name: 'node' }, mesh);
root.children.append(node);
颜色和贴图的变化,绝大多数情况都属于材质层面的变化,直接替换 materialOverride 就好,不需要重建几何体:
geometry.mesh.materialOverride = newMaterial;
本篇小结
这一篇真正需要记住的东西,其实不多:
- Geometry:负责进入场景树,成为可见节点
- Mesh:负责几何形状数据
- Material:负责表面表现
- Image:负责图片资源
- baseColor.image:负责贴图
- baseColor.factor:负责颜色因子
- materialOverride:运行时换材质的关键入口
走到这里,这个系列已经从“让立方体跑起来”,推进到了“能拆清楚一个 3D 对象到底由哪些资源组成”。
因为当几何、材质、纹理都能被正确组织之后,下一个自然的问题就是:这些对象怎么动起来,动画数据又是如何驱动节点属性变化的。




