opus关于图片的bug#
Data: 2026-04-08 15:09:15
深度分析:为什么画板显示 “handleImageMode…” 但图片实际已加载#
问题本质#
让我先确认你描述的现象:
三维空间的画板上显示着
handleImageMode...文字,但 HotInfo 面板能看到图片信息,点击"查看原图"也能看到图片,甚至画板的宽高已经变化了(说明图片尺寸已经拿到),可画板上的纹理还是那段文字。
根因分析#
问题出在 errorTexture_diy 这个 Hook 的执行时序上。这是一个同步 Hook,但图片加载是异步的。
关键代码流程#
errorTexture_diy Hook 被调用
├── ctx 是 canvas 的 2D 上下文(同步绘制)
├── mode === 'image' 时:
│ ├── ① drawSmartText(ctx, ..., 'handleImageMode...') ← 同步:画了文字到 canvas
│ ├── ② handleImageMode(index, id, imgUrl, ccgxkObj) ← 异步:创建 img 元素
│ └── ③ Hook 返回 → dToBase64() 立刻把 canvas 转成 base64 → 生成纹理
│
└── 之后某个时刻...
└── ④ imgEl.onload 触发 → ccgxkObj.W.plane({ n: 'T'+index, t: imgEl, ... })问题就在 ③ 和 ④ 之间的竞态。
为什么是"偶尔"发生(~10%-20%)?#
情况 A:图片加载快(~80-90% 的时间)✅ 正常#
① Hook 画了 "handleImageMode..." 到 canvas → ③ 生成了文字纹理
④ img.onload 很快触发 → W.plane() 用真实图片覆盖了文字纹理用户看到的是:文字一闪而过,很快被图片替换。看起来正常。
情况 B:图片加载慢 或 W.plane() 调用被"吞掉"(~10-20%)❌ 出 Bug#
这里有 两个可能的子原因:
子原因 1:W.plane() 在 onload 中被调用时,引擎的渲染队列状态不对#
// handleImageMode 中的 onload
imgEl.onload = () => {
const { w, h } = calcAspectScale(imgEl.naturalWidth, imgEl.naturalHeight);
// ...
ccgxkObj.W.plane({
n: 'T' + index,
t: imgEl,
w, h,
ns: 1,
});
// 档案更新了宽高 ← 这就是你看到"宽度已经变化了"的原因!
ccgxkObj.physicsProps[p_offset + 1] = w;
ccgxkObj.physicsProps[p_offset + 2] = h;
};宽高更新了(physicsProps 被写入了),但 W.plane() 的纹理更新可能没生效。
为什么?因为在 errorTexture_diy Hook 返回后,loadTexture 函数的 .then() 回调也会调用 W.plane():
// addObj.js 中的 activeTABox
this.loadTexture([{
func: this.errorTexture, // ← 这会触发你的 Hook
id: args.texture,
// ...
}]).then(res => {
this.W[args.shape]({ // ← 这个 .then() 会再次调用 W.plane()
n: 'T' + index,
t: this.textureMap.get(args.texture), // ← 用的是 textureMap 里的纹理
mix: args.mixValue,
});
});竞态时序:
时间线 →
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
① Hook 执行:canvas 画了 "handleImageMode..."
② loadTexture Promise resolve → textureMap 里存的是"文字纹理"的 Image
③ .then() 执行:W.plane({ t: textureMap.get(id) }) → 画板显示"文字纹理" ✍️
... 几十毫秒后 ...
④ img.onload:W.plane({ t: imgEl }) → 画板应该显示图片 🖼️
但是!如果 ③ 和 ④ 执行顺序被微任务调度打乱...
④ img.onload 先执行 → W.plane 显示了图片
③ .then() 后执行 → W.plane 又用"文字纹理"覆盖回去了!!!🔥 这就是核心 Bug:.then() 回调把 onload 的成果覆盖了!#
当图片恰好在 loadTexture 的 Promise resolve 之前或几乎同时加载完成时,就会出现:
img.onload→W.plane({ t: imgEl })✅ 画板显示图片.then()→W.plane({ t: textureMap.get(id) })❌ 又被覆盖成文字纹理!
而且 textureMap.get(id) 拿到的是 loadTexture 内部 dToBase64 生成的那个 Image(内容是 “handleImageMode…” 文字),不是真正的图片!
子原因 2:浏览器图片缓存命中时的微妙时序#
当图片已经被浏览器缓存后:
imgEl.src = imgUrl; // 赋值
// 浏览器缓存命中 → onload 在当前微任务结束前就已入队
此时 onload 可能在 Hook 函数还没完全返回时就准备好了,但 JavaScript 事件循环的机制是 onload 会被推迟到当前同步代码执行完毕后。这导致:
- 有缓存时:
onload几乎立即触发,但仍在.then()之后 → 可能正常 - 无缓存时:
onload延迟触发 → 来得及覆盖 → 正常 - 半缓存/304:时序微妙 → 偶发 Bug
你看到的"宽度变化但还是文字"现象的完美解释#
// handleImageMode 中
imgEl.onload = () => {
// ✅ 这行执行了 → 宽高确实变了
ccgxkObj.physicsProps[p_offset + 1] = w;
ccgxkObj.physicsProps[p_offset + 2] = h;
// ✅ 这行也执行了 → 但被后面的 .then() 覆盖了
ccgxkObj.W.plane({ n: 'T' + index, t: imgEl, w, h, ns: 1 });
};
// activeTABox 的 .then() 紧接着执行:
this.W.plane({
n: 'T' + index,
t: this.textureMap.get(args.texture), // ← "handleImageMode..." 的纹理图!
mix: args.mixValue,
// 没有传 w, h → 不会改尺寸,但纹理被覆盖了!
});所以你看到:尺寸变了(因为 physicsProps 和第一次 W.plane 改了尺寸),但纹理是文字(因为 .then() 又覆盖回去了)。
100000% 防范方案#
方案:在 Hook 中标记 image 模式,阻止 .then() 的覆盖#
最小改动,最大效果:
// ═══════════════════════════════════════════════════
// signTest.js - 修改 Hook 注册部分
// ═══════════════════════════════════════════════════
ccgxkObj.hooks.on('errorTexture_diy', function(ctx, width, height, drawItem, _this) {
const { index, id } = drawItem;
setCcgxkObj(ccgxkObj);
setTextureModule(_this);
signIndexMap.set(id, { index });
const info = signContentMap.get(id);
if (info) {
const { mode } = info;
if (mode === 'text') {
drawSmartText(ctx, width, height, info.t);
} else if (mode === 'image') {
// ══════════ 关键修改 ══════════
// 不再画 "handleImageMode..." 占位文字到 canvas
// 而是画一个"加载中"提示,并标记此纹理需要被图片替换
drawSmartText(ctx, width, height, '🖼️ Loading...');
// 标记这个 index 正在等待图片加载
// 用于阻止 .then() 回调覆盖真实图片纹理
ccgxkObj._pendingImageIndices = ccgxkObj._pendingImageIndices || new Set();
ccgxkObj._pendingImageIndices.add(index);
handleImageMode(index, id, info.imgUrl, ccgxkObj);
}
ccgxkObj.W.next['T' + index].hidden = false;
_this.indexToArgs.get(index).isInvisible = false;
} else {
lazyLoadSign(id);
drawSmartText(ctx, width, height, 'lazyLoadSign...');
}
});然后,修改 addObj.js 中 activeTABox 的 .then() 回调,加一个守卫:
// ═══════════════════════════════════════════════════
// addObj.js - 修改 activeTABox 中的 .then() 部分
// ═══════════════════════════════════════════════════
if(textureError){
const expRatio = this.errExpRatio * args.textureRatio;
const cWidth = (physicalProp[1] - utter) * expRatio;
const cHeight = (physicalProp[2] - utter) * expRatio;
this.loadTexture([{
func: this.errorTexture,
id: args.texture,
type: 'png',
width: cWidth,
height: cHeight,
index: index,
}]).then(res => {
// ══════════ 关键守卫 ══════════
// 如果这个 index 正在等待真实图片加载,就不要用占位纹理覆盖
if (this._pendingImageIndices && this._pendingImageIndices.has(index)) {
// 图片模式,跳过 .then() 的纹理覆盖
return;
}
this.W[args.shape]({
n: 'T' + index,
t: this.textureMap.get(args.texture),
mix: args.mixValue,
});
});
}最后,在 handleImageMode 的 onload 成功后,清除标记:
// ═══════════════════════════════════════════════════
// signTest.js - 修改 handleImageMode 函数
// ═══════════════════════════════════════════════════
function handleImageMode(index, id, imgUrl, ccgxkObj) {
const uniqueImgId = 'dyn_img_' + index + '_' + id;
let imgEl = document.getElementById(uniqueImgId);
const calcAspectScale = (imgW, imgH) => {
return { w: imgW / imgH * 2, h: 2 };
};
// 图片加载成功后的统一处理
const applyTexture = (img) => {
const { w, h } = calcAspectScale(img.naturalWidth, img.naturalHeight);
const textureModule = getTextureModule();
if (textureModule) {
textureModule.textureMap.set(id, img);
}
ccgxkObj.W.plane({
n: 'T' + index,
t: img,
w, h,
ns: 1,
});
const p_offset = index * 8;
ccgxkObj.physicsProps[p_offset + 1] = w;
ccgxkObj.physicsProps[p_offset + 2] = h;
// ══════════ 清除待处理标记 ══════════
if (ccgxkObj._pendingImageIndices) {
ccgxkObj._pendingImageIndices.delete(index);
}
};
if (!imgEl) {
imgEl = document.createElement('img');
imgEl.id = uniqueImgId;
imgEl.crossOrigin = 'anonymous';
imgEl.style.display = 'none';
document.body.appendChild(imgEl);
imgEl.onload = () => applyTexture(imgEl);
imgEl.onerror = () => {
console.error("图片加载失败:", imgUrl);
// 失败时也清除标记,允许 fallback 纹理显示
if (ccgxkObj._pendingImageIndices) {
ccgxkObj._pendingImageIndices.delete(index);
}
};
imgEl.src = imgUrl;
} else {
if (imgEl.complete && imgEl.naturalWidth > 0) {
applyTexture(imgEl);
} else {
imgEl.onload = () => applyTexture(imgEl);
imgEl.onerror = () => {
console.error("图片加载失败:", imgUrl);
if (ccgxkObj._pendingImageIndices) {
ccgxkObj._pendingImageIndices.delete(index);
}
};
}
}
}总结#
| 现象 | 原因 |
|---|---|
画板显示 handleImageMode... | Hook 同步绘制的占位文字被 .then() 当作最终纹理应用 |
| HotInfo 和 Panel 能看到图片 | 它们读的是 signContentMap,数据层是正确的 |
| 宽高已经变化 | img.onload 确实执行了,physicsProps 被更新了 |
| 偶发 10%-20% | 取决于图片加载速度与 .then() 微任务的竞态时序 |
| 点击原图能看 | signContentMap 里 imgUrl 是正确的,与画板纹理无关 |
根因:loadTexture().then() 的纹理覆盖回调与 img.onload 的纹理更新之间存在竞态条件(Race Condition)。
修复核心:用一个 Set 标记正在等待图片的 index,在 .then() 中跳过覆盖。