纹渊 HarmonyOS 7 工程实战(04):3D 材质贴图:纹样如何进入模型节点
一、问题与目标
3D 预览真正有价值的地方,不是把模型摆出来,而是让用户生成的纹样进入模型材质。否则 3D 容器只是一个静态展示区,和创作流程没有形成闭环。对纹渊来说,用户关心的是“这个纹样贴到杯体、徽章或包装盒上是否成立”,而不是单独看一个模型能不能加载。
纹样进入 3D 模型通常会经过几步:生成或绘制得到图片,图片进入可访问的沙箱路径,模型加载完成后找到目标材质节点,再把贴图绑定到节点上。任意一步失败,页面都要能说明原因,而不是只留下一个灰色预览区。
HarmonyOS 的 3D 能力可以对照 ArkGraphics 3D 概述。本文重点放在业务层的贴图链路:图片结果、模型目录、材质槽位和预览状态如何串起来。

二、实现思路
实现上要先明确材质槽位。不同模型的可贴图区不一样,杯体可能对应 cup_body_pattern,徽章可能对应 badge_face,包装盒可能对应 box_front。把槽位写进模型目录,比在页面里按模型名称判断更稳。
异步顺序也要收敛。图片结果和模型场景谁先回来都可能发生,因此状态层需要同时记录 textureReady 和 modelReady。只有两者都满足时才执行绑定;否则页面显示等待、降级或重试入口。
贴图绑定不是一次点击事件。用户可能先生成图片再打开三维预览,也可能先打开模型再重新生成纹样;还可能在模型加载过程中切换载体。状态层需要把“当前纹样版本”和“当前模型版本”绑定起来,避免旧图贴到新模型上。
| 贴图链路 | 处理方式 |
|---|---|
| 纹样结果 | 来自 AI 生图或 Canvas 绘制,先统一成图片载荷 |
| 沙箱路径 | 把临时结果转成运行时可访问的资源 |
| 材质槽位 | 根据模型目录找到目标节点,不在页面里硬编码 |
| 预览刷新 | 绑定成功后刷新 3D 视图,失败时回到二维预览 |
| 状态字段 | 作用 |
|---|---|
| textureVersion | 标记当前纹样结果,避免旧生成结果覆盖新贴图 |
| modelVersion | 标记当前模型实例,切换载体后旧模型不再接收贴图 |
| materialSlot | 从模型目录读取目标材质槽位 |
| bindState | 记录 idle/loading/success/failed,驱动页面提示 |
三、关键实现
先把图片结果统一成贴图载荷。AI 生图、Canvas 绘制和本地选择图片都可以进入同一条链路,页面不需要关心它们来自哪里。
interface TextureAsset {
uri: string
width: number
height: number
source: 'ai' | 'canvas' | 'local'
version: number
}
function normalizeTextureAsset(result: ImageResult, version: number): TextureAsset {
return {
uri: result.localUri,
width: result.width || 1024,
height: result.height || 1024,
source: result.source,
version
}
}
再做材质槽位查找。服务层必须返回“找到哪个槽位、绑定是否成功、失败原因是什么”,不能只返回一个布尔值。否则节点名变化后,页面可能误以为贴图已经成功。
async function findMaterialSlot(scene: Scene, slotName: string): Promise<MaterialSlotResult> {
const nodes = scene.root?.children ?? []
for (const node of nodes) {
const material = node.getComponent?.('material') as Material | undefined
if (material?.name === slotName) {
return { ok: true, nodeName: node.name, material }
}
}
return {
ok: false,
reason: `material slot ${slotName} not found`
}
}
最后把贴图写入材质,并用版本号阻止旧结果回填。这个逻辑适合放在 3D 预览服务里,而不是写在按钮点击事件中。
async function bindPatternTexture(task: TextureBindTask): Promise<TextureBindResult> {
const slot = await findMaterialSlot(task.scene, task.materialSlot)
if (!slot.ok) {
return { ok: false, reason: slot.reason }
}
const texture = await task.factory.createTexture({
name: `pattern-${task.texture.version}`,
uri: task.texture.uri
})
if (task.currentVersion() !== task.texture.version) {
return { ok: false, reason: 'stale texture result ignored' }
}
slot.material.setTexture('baseColor', texture)
return { ok: true, nodeName: slot.nodeName, slotName: task.materialSlot }
}
页面层只接收绑定结果,并把它转成明确的预览状态。这样“模型加载成功但贴图失败”和“模型本身不可用”不会混在一起。
function reduceTextureState(prev: Preview3DState, result: TextureBindResult): Preview3DState {
if (result.ok) {
return {
...prev,
bindState: 'success',
boundNode: result.nodeName,
boundSlot: result.slotName,
errorText: ''
}
}
return {
...prev,
bindState: 'failed',
boundNode: '',
boundSlot: '',
errorText: result.reason,
fallbackMode: 'canvas'
}
}
重点在于让页面只等待一个明确结果:贴图是否已经进入目标材质。模型加载、节点查找、图片解码这些细节都留在服务层,页面拿到成功或失败状态后再刷新预览。
如果模型已经加载但图片还没准备好,3D 区域可以显示默认材质;如果图片已经生成但模型加载失败,页面可以保留平面预览和导出能力。这样用户不会因为 3D 单点失败而失去整个创作结果。
这类状态机的价值在调试时很明显。看到 bindState=failed 时可以先检查材质槽位,看到 fallbackMode=canvas 时说明业务链路还保留二维结果。读者复现时也不需要猜测失败发生在哪一层。
四、失败场景与取舍
材质贴图最容易出现“看起来成功、实际没生效”的问题。比如节点名称变化后仍然返回成功,或者贴图尺寸不合适导致模型表面模糊。服务层应该返回绑定结果和目标槽位,必要时记录失败原因。
另一个取舍是不要把 3D 预览当成唯一结果。对创作类应用来说,AI 图和 Canvas 图本身也是成果。3D 预览用于增强确认感,但失败时必须能降级,不应该阻断保存、导出和分享。
贴图尺寸也要留边界。移动端预览不适合无脑使用超大原图,尤其是用户连续生成多张图时。可以在 Service 层把展示用贴图和导出原图分开:预览用压缩图保证帧率,导出保留原图,二者都绑定同一个纹样版本。
如果后续接入更多模型,优先扩展材质槽位目录,而不是在页面里继续追加条件分支。页面只表达当前预览状态,模型差异留给目录和服务层处理。
五、验证步骤
- 生成一张纹样后打开三维预览,确认预览卡片出现“纹样已贴合”或等价成功态。
- 连续生成两次纹样,确认旧纹样即使晚返回,也不会覆盖最后一次结果。
- 切换陶瓷杯和其他载体,确认不同模型读取各自的
materialSlot。 - 临时让材质槽位查找失败,确认页面回到二维预览,并显示可理解的失败原因。
- 使用较大图片和较小图片各试一次,确认预览不会明显卡顿,导出仍能使用原始结果。
六、总结
3D 材质贴图的关键是把图片结果、模型资源和材质槽位串成可恢复的异步链路。节点绑定成功就刷新预览,失败则保留二维结果和明确提示。
更多推荐



所有评论(0)