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) {
|
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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user