diff --git a/frontend/src/components/Live2DStage.tsx b/frontend/src/components/Live2DStage.tsx index 3434677..f5a3b3f 100644 --- a/frontend/src/components/Live2DStage.tsx +++ b/frontend/src/components/Live2DStage.tsx @@ -12,7 +12,8 @@ interface Props { * - Kira (center panel) * - 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. */ @@ -50,9 +51,7 @@ function positionCat(cat: any) { export default function Live2DStage({ onKiraReady, onReady, outfit }: Props) { const canvasRef = useRef(null); const kiraModelRef = useRef(null); - const clothTexIdxRef = useRef(-1); const pixiAppRef = useRef(null); - const defaultOutfitRef = useRef(null); useEffect(() => { let mounted = true; @@ -104,16 +103,6 @@ export default function Live2DStage({ onKiraReady, onReady, outfit }: Props) { 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.expression('Normal'); } catch {} @@ -126,7 +115,6 @@ export default function Live2DStage({ onKiraReady, onReady, outfit }: Props) { }); if (!mounted) return; - // Small delay to ensure PetZone DOM is rendered await new Promise(r => setTimeout(r, 100)); positionCat(catModel); (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 }, []); - // 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(() => { const model = kiraModelRef.current; - const texIdx = clothTexIdxRef.current; - if (!model || texIdx < 0 || !outfit) return; + if (!model || !outfit) return; 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; - // Load the outfit image - const img = new Image(); - img.crossOrigin = 'anonymous'; - img.onload = () => { + // Use PIXI's BaseTexture.from() which uses the shared texture cache + // and handles the image loading pipeline properly. + import('pixi.js').then((PIXI) => { if (cancelled) return; - try { - const renderer = pixiAppRef.current?.renderer; - if (!renderer) { console.warn('[outfit] no renderer'); return; } - - const gl: WebGLRenderingContext = renderer.gl || renderer.CONTEXT?.gl; - 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); + // Destroy old GL texture cache entry for the cloth slot + const oldTex = textures[CLOTH_IDX]; + const ctxId = pixiAppRef.current?.renderer?.CONTEXT_UID; + if (oldTex?.baseTexture?._glTextures?.[ctxId]) { + delete oldTex.baseTexture._glTextures[ctxId]; } - }; - img.onerror = () => { console.warn(`[outfit] failed to load ${outfitSrc}`); }; - img.src = outfitSrc; + + // 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}`); + }; + + 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; }; }, [outfit]);