fix(outfit): PIXI BaseTexture.from() + GL cache invalidation

Uses PIXI.BaseTexture.from(url) for proper pipeline.
Deletes old texture's _glTextures entry before swap.
Deletes new texture's _glTextures after swap to force re-upload.
Render loop (line 4960) detects missing GL entry and re-binds.
This commit is contained in:
2026-06-05 16:11:04 -04:00
parent 235f049405
commit d2bde65645
+60 -55
View File
@@ -12,7 +12,8 @@ interface Props {
* - Kira (center panel) * - Kira (center panel)
* - Mochi the cat (PetZone, bottom of right sidebar) * - Mochi the cat (PetZone, bottom of right sidebar)
* *
* Canvas sits behind UI panels (z-0, pointer-events: none). * Canvas sits above UI panels (z-50, pointer-events: none) so Live2D
* models render on top of the frosted-glass sidebars.
* Cat position is dynamically measured from the [data-petzone] DOM element. * Cat position is dynamically measured from the [data-petzone] DOM element.
*/ */
@@ -50,9 +51,7 @@ function positionCat(cat: any) {
export default function Live2DStage({ onKiraReady, onReady, outfit }: Props) { export default function Live2DStage({ onKiraReady, onReady, outfit }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const kiraModelRef = useRef<any>(null); const kiraModelRef = useRef<any>(null);
const clothTexIdxRef = useRef<number>(-1);
const pixiAppRef = useRef<any>(null); const pixiAppRef = useRef<any>(null);
const defaultOutfitRef = useRef<any>(null);
useEffect(() => { useEffect(() => {
let mounted = true; let mounted = true;
@@ -104,16 +103,6 @@ export default function Live2DStage({ onKiraReady, onReady, outfit }: Props) {
kiraModelRef.current = kiraModel; kiraModelRef.current = kiraModel;
// Find the clothing texture index (texture_02 = the outfit sheet)
try {
const textures = kiraModel.textures;
if (textures && textures.length >= 3) {
clothTexIdxRef.current = 2; // texture_02 is the outfit
// Save the default outfit texture so we can restore it
defaultOutfitRef.current = textures[2];
}
} catch {}
try { kiraModel.motion('Idle'); } catch {} try { kiraModel.motion('Idle'); } catch {}
try { kiraModel.expression('Normal'); } catch {} try { kiraModel.expression('Normal'); } catch {}
@@ -126,7 +115,6 @@ export default function Live2DStage({ onKiraReady, onReady, outfit }: Props) {
}); });
if (!mounted) return; if (!mounted) return;
// Small delay to ensure PetZone DOM is rendered
await new Promise(r => setTimeout(r, 100)); await new Promise(r => setTimeout(r, 100));
positionCat(catModel); positionCat(catModel);
(catModel as any).isInteractive = () => false; (catModel as any).isInteractive = () => false;
@@ -154,56 +142,73 @@ export default function Live2DStage({ onKiraReady, onReady, outfit }: Props) {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
// Outfit swap: load PNG → create WebGL texture → inject into Cubism renderer // Outfit swap effect
//
// The cubism4 _render loop (cubism4.es.js:4955-4965) iterates model.textures[]
// every frame. For each entry it calls:
// renderer.texture.bind(texture.baseTexture, 0) -- uploads to GPU if needed
// internalModel.bindTexture(i, glTextureHandle) -- tells Cubism to use it
//
// So the correct fix is to replace model.textures[2] with a new PIXI.Texture
// whose BaseTexture is loaded from the outfit PNG. On the next frame, the
// render loop will detect the missing _glTextures entry, upload the new image
// to WebGL, and pass the handle to the Cubism renderer.
useEffect(() => { useEffect(() => {
const model = kiraModelRef.current; const model = kiraModelRef.current;
const texIdx = clothTexIdxRef.current; if (!model || !outfit) return;
if (!model || texIdx < 0 || !outfit) return;
const outfitSrc = `/live2d/models/kira/outfits/${outfit}.png`; const outfitSrc = `/live2d/models/kira/outfits/${outfit}.png`;
// Find the clothing texture index by checking which texture file is at index 2
// model.textures[] is PIXI.Texture[] populated during model load from
// the texture_00.png, texture_01.png, texture_02.png files.
// texture_02 = clothes layer.
const CLOTH_IDX = 2;
const textures: any[] = model.textures;
if (!textures || textures.length <= CLOTH_IDX) {
console.warn('[outfit] model.textures too short:', textures?.length);
return;
}
let cancelled = false; let cancelled = false;
// Load the outfit image // Use PIXI's BaseTexture.from() which uses the shared texture cache
const img = new Image(); // and handles the image loading pipeline properly.
img.crossOrigin = 'anonymous'; import('pixi.js').then((PIXI) => {
img.onload = () => {
if (cancelled) return; if (cancelled) return;
try { // Destroy old GL texture cache entry for the cloth slot
const renderer = pixiAppRef.current?.renderer; const oldTex = textures[CLOTH_IDX];
if (!renderer) { console.warn('[outfit] no renderer'); return; } const ctxId = pixiAppRef.current?.renderer?.CONTEXT_UID;
if (oldTex?.baseTexture?._glTextures?.[ctxId]) {
const gl: WebGLRenderingContext = renderer.gl || renderer.CONTEXT?.gl; delete oldTex.baseTexture._glTextures[ctxId];
if (!gl) { console.warn('[outfit] no GL context'); return; }
// 1. Create a new WebGL texture from the outfit image
const newGLTex = gl.createTexture();
if (!newGLTex) { console.warn('[outfit] failed to create GL texture'); return; }
gl.bindTexture(gl.TEXTURE_2D, newGLTex);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
gl.bindTexture(gl.TEXTURE_2D, null);
// 2. Inject directly into the Cubism renderer's texture map
// bypassing PIXI entirely
const cubismRenderer = model.internalModel?.renderer;
if (cubismRenderer && typeof cubismRenderer.bindTexture === 'function') {
cubismRenderer.bindTexture(texIdx, newGLTex);
console.log(`[outfit] injected GL texture for idx=${texIdx}: ${outfit}`);
} else {
console.warn('[outfit] no cubism renderer or bindTexture');
}
} catch (e) {
console.warn('[outfit] swap failed:', e);
} }
};
img.onerror = () => { console.warn(`[outfit] failed to load ${outfitSrc}`); }; // Create new BaseTexture from the outfit URL (PIXI handles the Image load)
img.src = outfitSrc; const newBase = PIXI.BaseTexture.from(outfitSrc, {
scaleMode: PIXI.SCALE_MODES.LINEAR,
});
const doSwap = () => {
if (cancelled) return;
const newTex = new PIXI.Texture(newBase);
textures[CLOTH_IDX] = newTex;
// Force-delete any cached GL texture so the render loop re-uploads
if (newBase._glTextures?.[ctxId]) {
delete newBase._glTextures[ctxId];
}
console.log(`[outfit] swapped texture[${CLOTH_IDX}] → ${outfit}`);
};
if (newBase.valid) {
doSwap();
} else {
newBase.once('loaded', doSwap);
newBase.once('error', (e: any) => console.warn('[outfit] baseTexture load error:', e));
}
}).catch(e => console.warn('[outfit] pixi import failed:', e));
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [outfit]); }, [outfit]);