fix(avatar): declarative canvas element in JSX; remove manual DOM append

React was potentially clearing the canvas on re-render because we
appended it manually to a div. Now using a <canvas ref={canvasRef}>
element directly in JSX that React manages. Pixi app uses .
Scale set to 82% of container.
This commit is contained in:
2026-06-05 10:10:03 -04:00
parent e00dc37e68
commit 95f97fa897
+21 -20
View File
@@ -23,7 +23,7 @@ const OUTFIT_TEXTURES: Record<string, string> = {
}; };
export default function KiraAvatar(props: Props) { export default function KiraAvatar(props: Props) {
const wrapRef = useRef<HTMLDivElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const modelRef = useRef<any>(null); const modelRef = useRef<any>(null);
const textureRef = useRef<any>(null); const textureRef = useRef<any>(null);
const lipSyncRef = useRef<number>(0); const lipSyncRef = useRef<number>(0);
@@ -32,11 +32,10 @@ export default function KiraAvatar(props: Props) {
const [loadError, setLoadError] = useState(false); const [loadError, setLoadError] = useState(false);
const [currentExpression, setCurrentExpression] = useState<ExpressionName>('Normal'); const [currentExpression, setCurrentExpression] = useState<ExpressionName>('Normal');
// Initialize Live2D
useEffect(() => { useEffect(() => {
let mounted = true; let mounted = true;
const container = wrapRef.current; const canvas = canvasRef.current;
if (!container) return; if (!canvas) return;
let app: any = null; let app: any = null;
let model: any = null; let model: any = null;
@@ -51,8 +50,13 @@ export default function KiraAvatar(props: Props) {
const { Live2DModel } = await import('pixi-live2d-display/cubism4'); const { Live2DModel } = await import('pixi-live2d-display/cubism4');
(Live2DModel as any).registerTicker(Ticker as any); (Live2DModel as any).registerTicker(Ticker as any);
const w = canvas.clientWidth || 260;
const h = canvas.clientHeight || 320;
app = new Application({ app = new Application({
resizeTo: container, view: canvas,
width: w,
height: h,
antialias: true, antialias: true,
resolution: Math.min(window.devicePixelRatio || 1, 2), resolution: Math.min(window.devicePixelRatio || 1, 2),
backgroundAlpha: 0, backgroundAlpha: 0,
@@ -60,8 +64,6 @@ export default function KiraAvatar(props: Props) {
}); });
if (!mounted) { app.destroy(true); return; } if (!mounted) { app.destroy(true); return; }
container.appendChild(app.view as HTMLCanvasElement);
model = await Live2DModel.from('/live2d/models/kira/kira.model3.json', { model = await Live2DModel.from('/live2d/models/kira/kira.model3.json', {
autoInteract: false, autoInteract: false,
}); });
@@ -70,9 +72,7 @@ export default function KiraAvatar(props: Props) {
const fit = () => { const fit = () => {
const sw = app.screen.width; const sw = app.screen.width;
const sh = app.screen.height; const sh = app.screen.height;
// Scale to fit within the container, leaving margin so nothing clips const s = Math.min((sw * 0.82) / model.width, (sh * 0.82) / model.height);
const margin = 0.9; // 90% of container
const s = Math.min((sw * margin) / model.width, (sh * margin) / model.height);
model.scale.set(s); model.scale.set(s);
model.anchor.set(0.5, 0.5); model.anchor.set(0.5, 0.5);
model.position.set(sw / 2, sh / 2); model.position.set(sw / 2, sh / 2);
@@ -82,9 +82,6 @@ export default function KiraAvatar(props: Props) {
app.stage.addChild(model as any); app.stage.addChild(model as any);
(model as any).isInteractive = () => false; (model as any).isInteractive = () => false;
// Re-fit on resize
app.renderer.on('resize', fit);
try { try {
textureRef.current = { textureRef.current = {
index: 2, index: 2,
@@ -191,10 +188,14 @@ export default function KiraAvatar(props: Props) {
return ( return (
<div className="flex flex-col items-center w-full h-full overflow-hidden"> <div className="flex flex-col items-center w-full h-full overflow-hidden">
<div className="relative w-full flex-1" style={{ minHeight: 250 }}> <div className="relative w-full flex-1" style={{ minHeight: 250 }}>
{/* Pixi canvas mounts here */} {/* Live2D canvas */}
<div ref={wrapRef} className="w-full h-full" /> <canvas
ref={canvasRef}
className="w-full h-full block"
style={{ opacity: live2dReady ? 1 : 0 }}
/>
{/* SVG fallback when Live2D fails */} {/* SVG fallback */}
{(!live2dReady || loadError) && ( {(!live2dReady || loadError) && (
<div className="absolute inset-0 flex items-center justify-center"> <div className="absolute inset-0 flex items-center justify-center">
<AnimatedAvatar <AnimatedAvatar
@@ -207,7 +208,7 @@ export default function KiraAvatar(props: Props) {
</div> </div>
)} )}
{/* Loading spinner */} {/* Loading */}
{!live2dReady && !loadError && ( {!live2dReady && !loadError && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2"> <div className="absolute inset-0 flex flex-col items-center justify-center gap-2">
<div className="w-8 h-8 border-3 border-kira-pink border-t-transparent rounded-full animate-spin" /> <div className="w-8 h-8 border-3 border-kira-pink border-t-transparent rounded-full animate-spin" />
@@ -215,7 +216,7 @@ export default function KiraAvatar(props: Props) {
</div> </div>
)} )}
{/* Expression buttons overlay top */} {/* Expression buttons */}
{live2dReady && ( {live2dReady && (
<> <>
<div className="absolute top-1 left-0 right-0 flex gap-1 justify-center flex-wrap pointer-events-none"> <div className="absolute top-1 left-0 right-0 flex gap-1 justify-center flex-wrap pointer-events-none">
@@ -238,7 +239,7 @@ export default function KiraAvatar(props: Props) {
</div> </div>
</div> </div>
{/* Talk button overlay bottom-center */} {/* Talk button */}
<div className="absolute bottom-2 left-0 right-0 flex justify-center pointer-events-none"> <div className="absolute bottom-2 left-0 right-0 flex justify-center pointer-events-none">
<button <button
onClick={props.onTalkToggle} onClick={props.onTalkToggle}
@@ -253,7 +254,7 @@ export default function KiraAvatar(props: Props) {
</button> </button>
</div> </div>
{/* Status overlay bottom-right */} {/* Status */}
<div className="absolute bottom-2 right-3 flex items-center gap-2 text-xs text-kira-plum/50 pointer-events-none"> <div className="absolute bottom-2 right-3 flex items-center gap-2 text-xs text-kira-plum/50 pointer-events-none">
<span className={`w-2 h-2 rounded-full ${ <span className={`w-2 h-2 rounded-full ${
props.isSpeaking ? 'bg-kira-pink animate-pulse' props.isSpeaking ? 'bg-kira-pink animate-pulse'