From 37f8bf59a026187c9a576241ec1a487e806cb269 Mon Sep 17 00:00:00 2001 From: hobokenchicken Date: Fri, 5 Jun 2026 13:08:18 -0400 Subject: [PATCH] fix(webgl): use forceCanvas for Live2DCat to avoid dual WebGL context conflicts Reverts shared-context approach. Live2DCat gets its own canvas with forceCanvas:true (Canvas2D renderer), which avoids the WebGL bindBuffer spam entirely. Cleaned up onAppReady prop from KiraAvatar. --- frontend/src/App.tsx | 4 +- frontend/src/components/KiraAvatar.tsx | 4 +- frontend/src/components/Live2DCat.tsx | 57 +++++++++++++++++--------- frontend/src/components/PetZone.tsx | 9 +--- 4 files changed, 41 insertions(+), 33 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8a4b51f..1960f1c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -33,7 +33,6 @@ export default function App() { const [currentOutfit, setCurrentOutfit] = useState('cozy-hoodie'); const [currentAcc, setCurrentAcc] = useState(null); const [textInput, setTextInput] = useState(''); - const [pixiApp, setPixiApp] = useState(null); useEffect(() => { if (preferences.scene) setCurrentSceneId(preferences.scene); @@ -148,7 +147,6 @@ export default function App() { outfit={currentOutfit} accessory={currentAcc} onTalkToggle={handleTalkToggle} - onAppReady={setPixiApp} /> @@ -157,7 +155,7 @@ export default function App() { - + diff --git a/frontend/src/components/KiraAvatar.tsx b/frontend/src/components/KiraAvatar.tsx index 8327b75..e91d522 100644 --- a/frontend/src/components/KiraAvatar.tsx +++ b/frontend/src/components/KiraAvatar.tsx @@ -7,7 +7,6 @@ interface Props { outfit: string; accessory: string | null; onTalkToggle: () => void; - onAppReady?: (app: any) => void; } type ExpressionName = 'Normal' | 'Smile' | 'Sad' | 'Angry' | 'Surprised' | 'Blushing'; @@ -82,8 +81,7 @@ export default function KiraAvatar(props: Props) { app.stage.addChild(model as any); (model as any).isInteractive = () => false; - - if (props.onAppReady) props.onAppReady(app); + if (!mounted) return; try { textureRef.current = { diff --git a/frontend/src/components/Live2DCat.tsx b/frontend/src/components/Live2DCat.tsx index e4b5e3f..9746558 100644 --- a/frontend/src/components/Live2DCat.tsx +++ b/frontend/src/components/Live2DCat.tsx @@ -1,42 +1,58 @@ import { useEffect, useRef } from 'react'; interface Props { - app: any; className?: string; } -export default function Live2DCat({ app, className }: Props) { - const modelRef = useRef(null); +export default function Live2DCat({ className }: Props) { + const canvasRef = useRef(null); useEffect(() => { - if (!app) return; let mounted = true; + const canvas = canvasRef.current; + if (!canvas) return; + + let app: any = null; const init = async () => { try { + const { Application, Ticker } = await import('pixi.js'); const { Live2DModel } = await import('pixi-live2d-display/cubism4'); + (Live2DModel as any).registerTicker(Ticker as any); + + const w = canvas.clientWidth || 120; + const h = canvas.clientHeight || 120; + + // forceCanvas avoids WebGL context conflicts with KiraAvatar's context + app = new Application({ + view: canvas, + width: w, + height: h, + antialias: true, + resolution: Math.min(window.devicePixelRatio || 1, 2), + backgroundAlpha: 0, + autoDensity: true, + forceCanvas: true, + }); + if (!mounted) { app.destroy(true); return; } const model = await Live2DModel.from('/live2d/models/little-cat/LittleCat.model3.json', { autoInteract: false, }); - if (!mounted) return; + if (!mounted) { app.destroy(true); return; } - // Scale to fit ~120x120 area on the shared stage const sw = app.screen.width; const sh = app.screen.height; - const targetW = 120; - const targetH = 120; - const s = Math.min(targetW / model.width, targetH / model.height); + const s = Math.min((sw * 0.85) / model.width, (sh * 0.85) / model.height); model.scale.set(s); model.anchor.set(0.5, 0.5); - // Position near top-left corner with some padding - model.position.set(80, 80); + model.position.set(sw / 2, sh / 2); app.stage.addChild(model as any); (model as any).isInteractive = () => false; - modelRef.current = model; try { model.motion('Idle'); } catch { /* */ } + } catch (e) { console.warn('[Live2DCat]', e); } @@ -46,14 +62,15 @@ export default function Live2DCat({ app, className }: Props) { return () => { mounted = false; - if (modelRef.current) { - app.stage.removeChild(modelRef.current); - modelRef.current.destroy?.(); - modelRef.current = null; - } + if (app) { app.destroy(true, { children: true }); } }; - }, [app]); + }, []); - // Live2DCat no longer renders its own canvas — it shares the KiraAvatar canvas - return null; + return ( + + ); } diff --git a/frontend/src/components/PetZone.tsx b/frontend/src/components/PetZone.tsx index c648f82..826a4f7 100644 --- a/frontend/src/components/PetZone.tsx +++ b/frontend/src/components/PetZone.tsx @@ -1,10 +1,6 @@ import Live2DCat from './Live2DCat'; -interface Props { - app: any; -} - -export default function PetZone({ app }: Props) { +export default function PetZone() { return (

@@ -12,8 +8,7 @@ export default function PetZone({ app }: Props) {

- {/* Cat model is rendered on the shared KiraAvatar canvas */} - + Mochi