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:
@@ -23,7 +23,7 @@ const OUTFIT_TEXTURES: Record<string, string> = {
|
||||
};
|
||||
|
||||
export default function KiraAvatar(props: Props) {
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const modelRef = useRef<any>(null);
|
||||
const textureRef = useRef<any>(null);
|
||||
const lipSyncRef = useRef<number>(0);
|
||||
@@ -35,21 +35,11 @@ export default function KiraAvatar(props: Props) {
|
||||
// Initialize Live2D
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
const container = canvasRef.current;
|
||||
const container = wrapRef.current;
|
||||
if (!container) return;
|
||||
|
||||
let app: 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 () => {
|
||||
try {
|
||||
@@ -61,14 +51,8 @@ export default function KiraAvatar(props: Props) {
|
||||
const { Live2DModel } = await import('pixi-live2d-display/cubism4');
|
||||
(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({
|
||||
width: w,
|
||||
height: h,
|
||||
resizeTo: container,
|
||||
antialias: true,
|
||||
resolution: Math.min(window.devicePixelRatio || 1, 2),
|
||||
backgroundAlpha: 0,
|
||||
@@ -76,30 +60,31 @@ export default function KiraAvatar(props: Props) {
|
||||
});
|
||||
if (!mounted) { app.destroy(true); return; }
|
||||
|
||||
// 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);
|
||||
canvasEl = canvas;
|
||||
container.appendChild(app.view as HTMLCanvasElement);
|
||||
|
||||
model = await Live2DModel.from('/live2d/models/kira/kira.model3.json', {
|
||||
autoInteract: false,
|
||||
});
|
||||
modelRef.current = model;
|
||||
|
||||
// Fit model with generous margin to avoid clipping
|
||||
const maxW = w * 0.45;
|
||||
const maxH = h * 0.45;
|
||||
const scale = Math.min(maxW / model.width, maxH / model.height);
|
||||
model.scale.set(scale);
|
||||
model.anchor.set(0.5, 0.5);
|
||||
model.position.set(app.screen.width / 2, app.screen.height / 2);
|
||||
const fit = () => {
|
||||
const sw = app.screen.width;
|
||||
const sh = app.screen.height;
|
||||
// Scale to fit within the container, leaving margin so nothing clips
|
||||
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.position.set(sw / 2, sh / 2);
|
||||
};
|
||||
fit();
|
||||
|
||||
app.stage.addChild(model as any);
|
||||
(model as any).isInteractive = () => false;
|
||||
|
||||
// Re-fit on resize
|
||||
app.renderer.on('resize', fit);
|
||||
|
||||
try {
|
||||
textureRef.current = {
|
||||
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();
|
||||
});
|
||||
ro.observe(container);
|
||||
init();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
ro.disconnect();
|
||||
cancelAnimationFrame(lipSyncRef.current);
|
||||
clearInterval(idleExprRef.current ?? undefined);
|
||||
if (app) { app.destroy(true, { children: true }); }
|
||||
@@ -160,9 +127,7 @@ export default function KiraAvatar(props: Props) {
|
||||
useEffect(() => {
|
||||
const model = modelRef.current;
|
||||
if (!model || !live2dReady) return;
|
||||
|
||||
cancelAnimationFrame(lipSyncRef.current);
|
||||
|
||||
if (props.isSpeaking) {
|
||||
let phase = 0;
|
||||
const animate = () => {
|
||||
@@ -170,8 +135,7 @@ export default function KiraAvatar(props: Props) {
|
||||
const openness = 0.25 + Math.sin(phase) * 0.35;
|
||||
try {
|
||||
model.internalModel.coreModel.setParameterValueByIndex(
|
||||
findParam(model, 'PARAM_MOUTH_OPEN_Y'),
|
||||
openness,
|
||||
findParam(model, 'PARAM_MOUTH_OPEN_Y'), openness,
|
||||
);
|
||||
} catch { /* */ }
|
||||
lipSyncRef.current = requestAnimationFrame(animate);
|
||||
@@ -180,12 +144,10 @@ export default function KiraAvatar(props: Props) {
|
||||
} else {
|
||||
try {
|
||||
model.internalModel.coreModel.setParameterValueByIndex(
|
||||
findParam(model, 'PARAM_MOUTH_OPEN_Y'),
|
||||
0,
|
||||
findParam(model, 'PARAM_MOUTH_OPEN_Y'), 0,
|
||||
);
|
||||
} catch { /* */ }
|
||||
}
|
||||
|
||||
return () => cancelAnimationFrame(lipSyncRef.current);
|
||||
}, [props.isSpeaking, live2dReady]);
|
||||
|
||||
@@ -193,10 +155,8 @@ export default function KiraAvatar(props: Props) {
|
||||
useEffect(() => {
|
||||
const model = modelRef.current;
|
||||
if (!model || !live2dReady) return;
|
||||
|
||||
const outfitUrl = OUTFIT_TEXTURES[props.outfit];
|
||||
if (!outfitUrl) return;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const { Assets } = await import('pixi.js');
|
||||
@@ -218,7 +178,6 @@ export default function KiraAvatar(props: Props) {
|
||||
if (!model || !live2dReady) return;
|
||||
}, [props.accessory, live2dReady]);
|
||||
|
||||
// Inline styles
|
||||
const pulseStyle = `
|
||||
@keyframes listening-pulse {
|
||||
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 (
|
||||
<div className="flex flex-col items-center w-full h-full overflow-hidden">
|
||||
<div className="relative w-full flex-1" style={{ minHeight: 250 }}>
|
||||
<div
|
||||
ref={canvasRef}
|
||||
className="w-full h-full"
|
||||
style={{ display: live2dReady ? 'block' : 'none' }}
|
||||
/>
|
||||
{/* Pixi canvas mounts here */}
|
||||
<div ref={wrapRef} className="w-full h-full" />
|
||||
|
||||
{/* SVG fallback when Live2D fails */}
|
||||
{(!live2dReady || loadError) && (
|
||||
@@ -319,8 +275,6 @@ export default function KiraAvatar(props: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
// Helpers
|
||||
|
||||
function loadScript(src: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (document.querySelector('script[src="' + src + '"]')) { resolve(); return; }
|
||||
|
||||
Reference in New Issue
Block a user