纹渊 HarmonyOS 7 工程实战(14):AI 生成图回填到 3D 材质的异步链路
一、网络 URL 不能直接等同于三维纹理
AI 服务返回的通常是远程图片 URL,三维资源工厂需要的却是可读取、生命周期明确的图像资源。若直接把远程字符串传进材质,网络波动、URL 过期和组件销毁都会变成难以定位的渲染问题。稳定链路应分为五步:确认当前结果仍有效、下载二进制、写入应用沙箱、创建三维图像资源、把材质绑定到所有目标子网格。
下面的运行结果展示了纹样已经贴合到陶瓷杯表面。它证明的不只是“生成了一张图片”,还包括纹样选择、载体选择、预览渲染和导出区域已经消费同一份结果。
| 阶段 | 输入 | 输出 | 失败时保留什么 |
|---|---|---|---|
| AI 生成 | prompt、授权、接口配置 | 图片 URL | 纹样与 prompt |
| 下载 | HTTPS URL | ArrayBuffer | 旧业务选择,不保留假图片 |
| 沙箱落盘 | 字节数组 | 稳定文件路径 | 可重试下载 |
| 资源创建 | 沙箱路径 | G3DImage | 原模型材质 |
| 材质绑定 | 图像与场景节点 | 已贴图模型 | 二维或原材质预览 |
二、请求结果先经过业务有效性检查
异步请求返回时,用户可能已经切换纹样、载体或退出页面。只检查 HTTP 200 不够,还要验证结果属于当前请求。可以用递增令牌标记最新任务;旧请求即使成功,也不能覆盖新选择。
@State aiImageUrl: string = '' @State aiBusy: boolean = false private requestToken: number = 0 private async generateImage(prompt: string): Promise<void> { const token = ++this.requestToken this.aiBusy = true this.aiImageUrl = '' try { const result = await requestAiImage( this.apiKey, prompt, this.imageApiUrl ) if (token !== this.requestToken || result.imageUrl.length === 0) { return } this.aiImageUrl = result.imageUrl this.syncContinuationSnapshot() } finally { if (token === this.requestToken) { this.aiBusy = false } } }清空旧 URL 放在请求开始处,避免新请求失败时页面继续显示旧图。令牌不等于真正取消网络连接,但能阻止过期回调修改当前状态;若底层请求支持取消,还应在页面退出时同时关闭连接。
三、下载与落盘要形成原子边界
下载代码必须区分响应码、结果类型和文件写入是否完成。直接用最终文件名写入时,组件可能在写到一半读取到损坏图片。更稳妥的做法是先写临时文件,完成后再替换目标文件。
async function downloadTexture(url: string, finalPath: string): Promise<string> { const request = http.createHttp() const tempPath = `${finalPath}.part` try { const response = await request.request(url, { method: http.RequestMethod.GET, expectDataType: http.HttpDataType.ARRAY_BUFFER, connectTimeout: 15000, readTimeout: 30000 }) if (response.responseCode !== 200 || !(response.result instanceof ArrayBuffer)) { return '' } const file = fs.openSync(tempPath, fs.OpenMode.CREATE | fs.OpenMode.TRUNC | fs.OpenMode.WRITE_ONLY) fs.writeSync(file.fd, response.result) fs.closeSync(file) fs.renameSync(tempPath, finalPath) return finalPath } finally { request.destroy() } }真实工程还应限制 Content-Type 和最大字节数,避免错误页或超大文件进入纹理创建阶段。目标文件名至少包含纹样 ID 与结果版本,不能让并发任务写入同一路径。
四、沙箱缓存要可失效、可回收
缓存的价值是把网络不确定性隔离在资源创建之前,但缓存不能无限增长。可用patternId + 内容摘要作为键;新的 AI 结果出现时生成新键,旧文件由定期清理策略回收。内置纹样与 AI 结果也应走统一的“准备沙箱纹理”接口。
async function prepareTexture(input: TextureInput): Promise<string> { const dir = `${getContext().cacheDir}/pattern_tex` ensureDirectory(dir) if (input.aiImageUrl.length > 0) { const key = hashText(input.aiImageUrl).substring(0, 12) const output = `${dir}/ai_${input.patternId}_${key}.png` if (fs.accessSync(output)) { return output } return await downloadTexture(input.aiImageUrl, output) } const output = `${dir}/builtin_${input.patternId}.png` if (!fs.accessSync(output)) { await packMediaResourceToPng(input.patternResource, output) } return output }“AI URL 为空”不代表失败,它表示使用内置纹样资源。只有准备函数返回空路径时,三维组件才进入可见错误或原材质回退。
五、创建图像资源后再遍历几何节点
沙箱文件可读后,资源工厂创建G3DImage,再为当前纹理构造 PBR 材质。模型可能包含多个 Geometry 和多个 SubMesh,只替换第一个材质会出现“杯身已贴图、杯把仍是旧色”的不完整结果。
private async applyPatternTexture(scene: Scene, factory: SceneResourceFactory, sandboxPath: string): Promise<boolean> { const image = await factory.createImage({ name: 'patternTexture', uri: sandboxPath }) const geometries = this.findGeometryNodes(scene) if (geometries.length === 0) { return await this.applyShaderFallback(factory, image) } const material = await factory.createMaterial( { name: 'patternPbrMaterial' }, MaterialType.METALLIC_ROUGHNESS ) as MetallicRoughnessMaterial material.baseColor = { image, factor: { x: 1, y: 1, z: 1, w: 1 } } material.cullMode = 0 geometries.forEach((geometry: Geometry) => { geometry.mesh?.subMeshes?.forEach((subMesh) => { subMesh.material = material }) }) return true }遍历函数需要处理空根节点和非 Geometry 节点,不能假设 GLB 结构永远固定。材质绑定成功后再把textureApplied设为真,UI 上的“纹样已贴合”才具有真实含义。
六、回退顺序要从局部到整体
找不到 Geometry 时,可以尝试 Shader 材质输入;Shader 也不可用时,继续展示原始模型;模型本身加载失败时,再切到二维预览。每次只缩减一层能力,避免一个材质异常直接清空整个创作状态。
private async applyShaderFallback(factory: SceneResourceFactory, image: G3DImage): Promise<boolean> { try { const material = await factory.createMaterial( { name: 'patternShaderMaterial' }, MaterialType.SHADER ) const shader = (material as ShaderMaterial).colorShader if (shader === undefined || shader === null) { return false } shader.inputs['BASE_COLOR_Image'] = image return true } catch { return false } } private showPreview(result: TextureResult): void { if (result.textureApplied) { this.previewState = 'textured3d' } else if (result.modelLoaded) { this.previewState = 'plain3d' } else { this.previewState = 'canvas2d' } }状态标签应真实反映结果:textured3d才能显示“纹样已贴合”;原模型只能显示“模型已加载”;二维回退则明确提示“当前设备使用二维预览”。
七、异常矩阵
| 异常 | 检测点 | 页面状态 | 恢复动作 |
|---|---|---|---|
| AI 返回空 URL | 生成结果解析 | 失败,不展示旧图 | 修改 prompt 后重试 |
| 图片 URL 过期 | 下载响应码 | 保留选择,纹理未应用 | 重新生成 |
| 返回非图片数据 | Content-Type/结果类型 | 拒绝落盘 | 更换接口或重试 |
| 写文件失败 | 临时文件写入 | 清理.part文件 | 检查空间后重试 |
| 页面已切换 | 请求令牌 | 丢弃旧回调 | 使用新任务结果 |
| Geometry 为空 | 场景遍历 | 尝试 Shader | 保留原模型 |
| 材质创建失败 | 资源工厂 | 原模型状态 | 切二维预览 |
| 设备不支持 3D | 模型加载 | 二维状态 | 继续导出图片 |
把错误写入日志还不够,用户至少要知道当前显示的是 AI 结果、内置纹样、原模型还是二维回退,否则无法判断生成按钮是否真正生效。
八、验证步骤
1. 生成一张新图,确认旧预览立即清空,加载结束后只出现新结果。 2. 切换纹样后立即再次生成,确认第一个请求即使晚返回也不会覆盖第二个结果。 3. 断网后触发下载,确认不会生成半文件,三维组件显示可恢复状态。 4. 检查缓存键,确认不同 AI URL 不会写入同一个目标文件。 5. 使用包含多个子网格的模型,确认所有可见表面材质一致更新。 6. 模拟 Geometry 为空,确认先走 Shader 回退;继续失败时仍显示原模型或二维预览。 7. 退出页面后等待旧请求完成,确认旧回调不会修改新页面状态。 8. 观察“纹样已贴合”标签,只在真实材质绑定成功后出现。
九、总结
AI 图片进入三维材质不是一次简单赋值,而是一条跨网络、文件系统和渲染资源的异步流水线。请求令牌阻止过期结果回写,临时文件保证落盘完整,沙箱缓存隔离远程 URL,PBR 材质遍历覆盖所有子网格,分层回退则让原模型和二维预览继续承接业务。每个阶段都有可观察状态,才能证明最终贴图属于当前纹样和当前载体。
网络请求的基础用法可参考HTTP 数据请求。