fix(avatar): use ResizeObserver for accurate container sizing; force canvas CSS 100%; reduce margin to 68%

Problem: flex layout wasn't ready on first paint, so clientWidth fell back
to 400px. Canvas was 400px wide but parent was only 288px, causing the
avatar to be clipped on the right.

Fix: ResizeObserver measures real laid-out size before init. Canvas forced
to width/height 100% via CSS so it never overflows. Model scaled to 68%
with centered anchor. Resize handled dynamically.
This commit is contained in:
2026-06-05 09:43:12 -04:00
parent dc2cb3bbb3
commit f2ff91730b
+49 -25
View File
@@ -39,49 +39,55 @@ export default function KiraAvatar(props: Props) {
const container = canvasRef.current; const container = canvasRef.current;
if (!container) return; if (!container) return;
(async () => { let app: any = null;
let model: any = null;
const init = async () => {
try { try {
const resp = await fetch('/live2d/models/kira/kira.model3.json', { method: 'HEAD' }); const resp = await fetch('/live2d/models/kira/kira.model3.json', { method: 'HEAD' });
if (!resp.ok) { if (!resp.ok) { if (mounted) setLoadError(true); return; }
if (mounted) setLoadError(true);
return;
}
await loadScript('/live2d/cubism/live2dcubismcore.min.js'); await loadScript('/live2d/cubism/live2dcubismcore.min.js');
const { Application, Ticker } = await import('pixi.js'); const { Application, Ticker } = await import('pixi.js');
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 containerW = container.clientWidth || 400; // Measure real container size (flex layout may not be ready on first paint)
const containerH = container.clientHeight || 400; const rect = container.getBoundingClientRect();
const app = new Application({ const w = Math.max(Math.round(rect.width), 260);
width: containerW, const h = Math.max(Math.round(rect.height), 260);
height: containerH,
app = new Application({
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,
autoDensity: true,
}); });
appRef.current = app;
if (!mounted) { app.destroy(true); return; } if (!mounted) { app.destroy(true); return; }
container.appendChild(app.view as HTMLCanvasElement); // Force canvas to fill container via CSS so it never overflows
const canvas = app.view as HTMLCanvasElement;
canvas.style.width = '100%';
canvas.style.height = '100%';
canvas.style.display = 'block';
container.appendChild(canvas);
const model = await Live2DModel.from('/live2d/models/kira/kira.model3.json', { model = await Live2DModel.from('/live2d/models/kira/kira.model3.json', {
autoInteract: false, autoInteract: false,
}); });
modelRef.current = model; modelRef.current = model;
// Fit model to fill the canvas — leave generous margin to avoid clipping // Fit model with generous margin to avoid clipping
const maxW = containerW * 0.72; const maxW = w * 0.68;
const maxH = containerH * 0.72; const maxH = h * 0.68;
const scale = Math.min(maxW / model.width, maxH / model.height); const scale = Math.min(maxW / model.width, maxH / model.height);
model.scale.set(scale); model.scale.set(scale);
model.anchor.set(0.5, 0.52); model.anchor.set(0.5, 0.5);
model.position.set(app.screen.width / 2, app.screen.height / 2 + 6); model.position.set(app.screen.width / 2, app.screen.height / 2);
app.stage.addChild(model as any); app.stage.addChild(model as any);
(model as any).isInteractive = () => false; (model as any).isInteractive = () => false;
try { try {
@@ -107,16 +113,34 @@ export default function KiraAvatar(props: Props) {
console.warn('[Live2D]', e); console.warn('[Live2D]', e);
if (mounted) setLoadError(true); if (mounted) setLoadError(true);
} }
})(); };
// Use ResizeObserver so we init with the real laid-out size
const ro = new ResizeObserver((entries) => {
if (app) {
// Already init'd — handle resize
const cr = entries[0].contentRect;
app.renderer.resize(Math.round(cr.width), Math.round(cr.height));
if (model) {
const maxW = cr.width * 0.68;
const maxH = cr.height * 0.68;
const s = Math.min(maxW / model.width, maxH / model.height);
model.scale.set(s);
model.position.set(app.screen.width / 2, app.screen.height / 2);
}
return;
}
// First measurement — run init
init();
});
ro.observe(container);
return () => { return () => {
mounted = false; mounted = false;
ro.disconnect();
cancelAnimationFrame(lipSyncRef.current); cancelAnimationFrame(lipSyncRef.current);
clearInterval(idleExprRef.current ?? undefined); clearInterval(idleExprRef.current ?? undefined);
if (appRef.current) { if (app) { app.destroy(true, { children: true }); }
appRef.current.destroy(true, { children: true });
appRef.current = null;
}
modelRef.current = null; modelRef.current = null;
textureRef.current = null; textureRef.current = null;
}; };