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:
@@ -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);
|
// Create new BaseTexture from the outfit URL (PIXI handles the Image load)
|
||||||
|
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}`);
|
||||||
};
|
};
|
||||||
img.onerror = () => { console.warn(`[outfit] failed to load ${outfitSrc}`); };
|
|
||||||
img.src = outfitSrc;
|
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]);
|
||||||
|
|||||||
Reference in New Issue
Block a user