fix(avatar): use Pixi resizeTo for native canvas sizing; remove all manual CSS/ResizeObserver

Previous approach set CSS width:100% on a low-res canvas, causing the browser
to stretch/pixelate the model. Now using Pixi's built-in resizeTo so the
canvas internal resolution always matches the container. Model scaled to 90%
of container with centered anchor.
This commit is contained in:
2026-06-05 09:57:54 -04:00
parent 3a6a1cd6c3
commit e00dc37e68
+21 -67
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 canvasRef = useRef<HTMLDivElement>(null); const wrapRef = useRef<HTMLDivElement>(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);
@@ -35,21 +35,11 @@ export default function KiraAvatar(props: Props) {
// Initialize Live2D // Initialize Live2D
useEffect(() => { useEffect(() => {
let mounted = true; let mounted = true;
const container = canvasRef.current; const container = wrapRef.current;
if (!container) return; if (!container) return;
let app: any = null; let app: any = null;
let model: any = null; let model: any = null;
let canvasEl: HTMLCanvasElement | null = null;
const fitModel = (crW: number, crH: number) => {
if (!model || !app) return;
const maxW = crW * 0.45;
const maxH = crH * 0.45;
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);
};
const init = async () => { const init = async () => {
try { try {
@@ -61,14 +51,8 @@ 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);
// Measure real container size (flex layout may not be ready on first paint)
const rect = container.getBoundingClientRect();
const w = Math.max(Math.round(rect.width), 260);
const h = Math.max(Math.round(rect.height), 260);
app = new Application({ app = new Application({
width: w, resizeTo: container,
height: h,
antialias: true, antialias: true,
resolution: Math.min(window.devicePixelRatio || 1, 2), resolution: Math.min(window.devicePixelRatio || 1, 2),
backgroundAlpha: 0, backgroundAlpha: 0,
@@ -76,30 +60,31 @@ export default function KiraAvatar(props: Props) {
}); });
if (!mounted) { app.destroy(true); return; } if (!mounted) { app.destroy(true); return; }
// Force canvas to fill container via CSS so it never overflows container.appendChild(app.view as HTMLCanvasElement);
const canvas = app.view as HTMLCanvasElement;
canvas.style.width = '100%';
canvas.style.height = '100%';
canvas.style.display = 'block';
container.appendChild(canvas);
canvasEl = canvas;
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 with generous margin to avoid clipping const fit = () => {
const maxW = w * 0.45; const sw = app.screen.width;
const maxH = h * 0.45; const sh = app.screen.height;
const scale = Math.min(maxW / model.width, maxH / model.height); // Scale to fit within the container, leaving margin so nothing clips
model.scale.set(scale); const margin = 0.9; // 90% of container
const s = Math.min((sw * margin) / model.width, (sh * margin) / model.height);
model.scale.set(s);
model.anchor.set(0.5, 0.5); model.anchor.set(0.5, 0.5);
model.position.set(app.screen.width / 2, app.screen.height / 2); model.position.set(sw / 2, sh / 2);
};
fit();
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,
@@ -125,28 +110,10 @@ export default function KiraAvatar(props: Props) {
} }
}; };
// Use ResizeObserver so we init with the real laid-out size
const ro = new ResizeObserver((entries) => {
const cr = entries[0].contentRect;
if (app) {
// Already init'd — handle resize
app.renderer.resize(Math.round(cr.width), Math.round(cr.height));
// Re-apply CSS 100% because Pixi resize() overwrites inline styles
if (canvasEl) {
canvasEl.style.width = '100%';
canvasEl.style.height = '100%';
}
fitModel(cr.width, cr.height);
return;
}
// First measurement — run init
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 (app) { app.destroy(true, { children: true }); } if (app) { app.destroy(true, { children: true }); }
@@ -160,9 +127,7 @@ export default function KiraAvatar(props: Props) {
useEffect(() => { useEffect(() => {
const model = modelRef.current; const model = modelRef.current;
if (!model || !live2dReady) return; if (!model || !live2dReady) return;
cancelAnimationFrame(lipSyncRef.current); cancelAnimationFrame(lipSyncRef.current);
if (props.isSpeaking) { if (props.isSpeaking) {
let phase = 0; let phase = 0;
const animate = () => { const animate = () => {
@@ -170,8 +135,7 @@ export default function KiraAvatar(props: Props) {
const openness = 0.25 + Math.sin(phase) * 0.35; const openness = 0.25 + Math.sin(phase) * 0.35;
try { try {
model.internalModel.coreModel.setParameterValueByIndex( model.internalModel.coreModel.setParameterValueByIndex(
findParam(model, 'PARAM_MOUTH_OPEN_Y'), findParam(model, 'PARAM_MOUTH_OPEN_Y'), openness,
openness,
); );
} catch { /* */ } } catch { /* */ }
lipSyncRef.current = requestAnimationFrame(animate); lipSyncRef.current = requestAnimationFrame(animate);
@@ -180,12 +144,10 @@ export default function KiraAvatar(props: Props) {
} else { } else {
try { try {
model.internalModel.coreModel.setParameterValueByIndex( model.internalModel.coreModel.setParameterValueByIndex(
findParam(model, 'PARAM_MOUTH_OPEN_Y'), findParam(model, 'PARAM_MOUTH_OPEN_Y'), 0,
0,
); );
} catch { /* */ } } catch { /* */ }
} }
return () => cancelAnimationFrame(lipSyncRef.current); return () => cancelAnimationFrame(lipSyncRef.current);
}, [props.isSpeaking, live2dReady]); }, [props.isSpeaking, live2dReady]);
@@ -193,10 +155,8 @@ export default function KiraAvatar(props: Props) {
useEffect(() => { useEffect(() => {
const model = modelRef.current; const model = modelRef.current;
if (!model || !live2dReady) return; if (!model || !live2dReady) return;
const outfitUrl = OUTFIT_TEXTURES[props.outfit]; const outfitUrl = OUTFIT_TEXTURES[props.outfit];
if (!outfitUrl) return; if (!outfitUrl) return;
(async () => { (async () => {
try { try {
const { Assets } = await import('pixi.js'); const { Assets } = await import('pixi.js');
@@ -218,7 +178,6 @@ export default function KiraAvatar(props: Props) {
if (!model || !live2dReady) return; if (!model || !live2dReady) return;
}, [props.accessory, live2dReady]); }, [props.accessory, live2dReady]);
// Inline styles
const pulseStyle = ` const pulseStyle = `
@keyframes listening-pulse { @keyframes listening-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(248, 113, 113, 0.4); } 0%, 100% { box-shadow: 0 0 0 0 rgba(248, 113, 113, 0.4); }
@@ -232,11 +191,8 @@ 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 }}>
<div {/* Pixi canvas mounts here */}
ref={canvasRef} <div ref={wrapRef} className="w-full h-full" />
className="w-full h-full"
style={{ display: live2dReady ? 'block' : 'none' }}
/>
{/* SVG fallback when Live2D fails */} {/* SVG fallback when Live2D fails */}
{(!live2dReady || loadError) && ( {(!live2dReady || loadError) && (
@@ -319,8 +275,6 @@ export default function KiraAvatar(props: Props) {
); );
} }
// Helpers
function loadScript(src: string): Promise<void> { function loadScript(src: string): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (document.querySelector('script[src="' + src + '"]')) { resolve(); return; } if (document.querySelector('script[src="' + src + '"]')) { resolve(); return; }