一、问题与目标

3D 预览真正有价值的地方,不是把模型摆出来,而是让用户生成的纹样进入模型材质。否则 3D 容器只是一个静态展示区,和创作流程没有形成闭环。对纹渊来说,用户关心的是“这个纹样贴到杯体、徽章或包装盒上是否成立”,而不是单独看一个模型能不能加载。

纹样进入 3D 模型通常会经过几步:生成或绘制得到图片,图片进入可访问的沙箱路径,模型加载完成后找到目标材质节点,再把贴图绑定到节点上。任意一步失败,页面都要能说明原因,而不是只留下一个灰色预览区。

HarmonyOS 的 3D 能力可以对照 ArkGraphics 3D 概述。本文重点放在业务层的贴图链路:图片结果、模型目录、材质槽位和预览状态如何串起来。

运行界面

二、实现思路

实现上要先明确材质槽位。不同模型的可贴图区不一样,杯体可能对应 cup_body_pattern,徽章可能对应 badge_face,包装盒可能对应 box_front。把槽位写进模型目录,比在页面里按模型名称判断更稳。

异步顺序也要收敛。图片结果和模型场景谁先回来都可能发生,因此状态层需要同时记录 textureReadymodelReady。只有两者都满足时才执行绑定;否则页面显示等待、降级或重试入口。

贴图绑定不是一次点击事件。用户可能先生成图片再打开三维预览,也可能先打开模型再重新生成纹样;还可能在模型加载过程中切换载体。状态层需要把“当前纹样版本”和“当前模型版本”绑定起来,避免旧图贴到新模型上。

贴图链路 处理方式
纹样结果 来自 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 层把展示用贴图和导出原图分开:预览用压缩图保证帧率,导出保留原图,二者都绑定同一个纹样版本。

如果后续接入更多模型,优先扩展材质槽位目录,而不是在页面里继续追加条件分支。页面只表达当前预览状态,模型差异留给目录和服务层处理。

五、验证步骤

  1. 生成一张纹样后打开三维预览,确认预览卡片出现“纹样已贴合”或等价成功态。
  2. 连续生成两次纹样,确认旧纹样即使晚返回,也不会覆盖最后一次结果。
  3. 切换陶瓷杯和其他载体,确认不同模型读取各自的 materialSlot
  4. 临时让材质槽位查找失败,确认页面回到二维预览,并显示可理解的失败原因。
  5. 使用较大图片和较小图片各试一次,确认预览不会明显卡顿,导出仍能使用原始结果。

六、总结

3D 材质贴图的关键是把图片结果、模型资源和材质槽位串成可恢复的异步链路。节点绑定成功就刷新预览,失败则保留二维结果和明确提示。

Logo

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

更多推荐