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 之前或几乎同时加载完成时,就会出现:

  1. img.onloadW.plane({ t: imgEl }) ✅ 画板显示图片
  2. .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.jsactiveTABox.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,
        });
    });
}

最后,在 handleImageModeonload 成功后,清除标记:

// ═══════════════════════════════════════════════════
// 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() 微任务的竞态时序
点击原图能看signContentMapimgUrl 是正确的,与画板纹理无关

根因:loadTexture().then() 的纹理覆盖回调与 img.onload 的纹理更新之间存在竞态条件(Race Condition)。

修复核心:用一个 Set 标记正在等待图片的 index,在 .then() 中跳过覆盖。

- end -#

© 2025 –   海牧羽工厂 HMY Factory