fix: 修复Live2D全页视线追踪范围

This commit is contained in:
sunlei 2026-07-15 21:46:16 +08:00
parent 30bc26c031
commit efd642c2ae
4 changed files with 103 additions and 27 deletions

View File

@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'
import { toCubism2ViewPoint } from '../../components/blog/live2d/runtime/cubism2PointerCoordinates'
describe('Cubism2 pointer coordinates', () => {
it('uses canvas-local coordinates and canvas width for both source axes', () => {
it('preserves source coordinates for points inside the canvas', () => {
const canvas = {
getBoundingClientRect: () => ({
bottom: 270,
@ -33,4 +33,35 @@ describe('Cubism2 pointer coordinates', () => {
y: 0,
})
})
it('projects page-level pointers onto the canvas boundary around the model center', () => {
const canvas = {
getBoundingClientRect: () => ({
bottom: 270,
height: 250,
left: 10,
right: 290,
top: 20,
width: 280,
x: 10,
y: 20,
toJSON: () => ({}),
}),
height: 500,
width: 560,
}
expect(toCubism2ViewPoint(canvas, { clientX: 1_000, clientY: 145 })).toEqual({
x: 1,
y: 0,
})
expect(toCubism2ViewPoint(canvas, { clientX: 150, clientY: -1_000 })).toEqual({
x: 0,
y: 500 / 560,
})
expect(toCubism2ViewPoint(canvas, { clientX: 710, clientY: -355 })).toEqual({
x: 1,
y: 500 / 560,
})
})
})

View File

@ -0,0 +1,32 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createWebGLLive2DRenderer } from '../../components/blog/live2d/runtime/webglLive2DRenderer'
describe('Cubism2 page-level pointer tracking', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('tracks mouse movement across the page and removes every listener on destroy', () => {
const canvas = document.createElement('canvas')
const windowAdd = vi.spyOn(window, 'addEventListener')
const windowRemove = vi.spyOn(window, 'removeEventListener')
const documentAdd = vi.spyOn(document, 'addEventListener')
const documentRemove = vi.spyOn(document, 'removeEventListener')
const canvasAdd = vi.spyOn(canvas, 'addEventListener')
const renderer = createWebGLLive2DRenderer(canvas)
expect(windowAdd).toHaveBeenCalledWith('mousemove', expect.any(Function))
expect(documentAdd).toHaveBeenCalledWith('mouseleave', expect.any(Function))
expect(canvasAdd).not.toHaveBeenCalledWith('mousemove', expect.any(Function))
expect(canvasAdd).not.toHaveBeenCalledWith('mouseout', expect.any(Function))
const mouseMoveHandler = windowAdd.mock.calls.find(([type]) => type === 'mousemove')?.[1]
const mouseLeaveHandler = documentAdd.mock.calls.find(([type]) => type === 'mouseleave')?.[1]
renderer.destroy()
expect(windowRemove).toHaveBeenCalledWith('mousemove', mouseMoveHandler)
expect(documentRemove).toHaveBeenCalledWith('mouseleave', mouseLeaveHandler)
})
})

View File

@ -2,15 +2,14 @@ export interface Cubism2ViewPoint {
x: number;
y: number;
}
type Cubism2PointerCanvas = Pick<HTMLCanvasElement, 'height' | 'width' | 'getBoundingClientRect'>;
type Cubism2ClientPoint = Pick<MouseEvent, 'clientX' | 'clientY'>;
/**
* Converts a browser pointer into the source runtime's canvas-local Cubism2 view coordinates.
* @param canvas Canvas whose drawing-buffer dimensions define `deviceToScreen`.
* Converts a page-level browser pointer into the bounded WordPress Cubism2 view range.
* @param canvas Canvas whose bounds and drawing-buffer dimensions define the view range.
* @param point Browser client coordinates from a mouse or touch event.
* @returns Source view point where both axes use canvas width as the scale denominator.
* @returns View point projected from the model center to the canvas boundary when needed.
*/
export function toCubism2ViewPoint(
canvas: Cubism2PointerCanvas,
@ -19,8 +18,21 @@ export function toCubism2ViewPoint(
const bounds = canvas.getBoundingClientRect();
const cssWidth = Math.max(bounds.width, 1);
const cssHeight = Math.max(bounds.height, 1);
const deviceX = (point.clientX - bounds.left) * canvas.width / cssWidth;
const deviceY = (point.clientY - bounds.top) * canvas.height / cssHeight;
const centerX = bounds.left + cssWidth / 2;
const centerY = bounds.top + cssHeight / 2;
const deltaX = point.clientX - centerX;
const deltaY = point.clientY - centerY;
const horizontalProjection = deltaX === 0
? Number.POSITIVE_INFINITY
: cssWidth / 2 / Math.abs(deltaX);
const verticalProjection = deltaY === 0
? Number.POSITIVE_INFINITY
: cssHeight / 2 / Math.abs(deltaY);
const projectionScale = Math.min(1, horizontalProjection, verticalProjection);
const projectedX = centerX + deltaX * projectionScale;
const projectedY = centerY + deltaY * projectionScale;
const deviceX = (projectedX - bounds.left) * canvas.width / cssWidth;
const deviceY = (projectedY - bounds.top) * canvas.height / cssHeight;
const coordinateScale = Math.max(canvas.width, 1);
return {
x: (deviceX - canvas.width / 2) * 2 / coordinateScale,

View File

@ -101,15 +101,12 @@ export function createWebGLLive2DRenderer(canvas: HTMLCanvasElement): Live2DRend
};
/**
* Tracks the mouse in the source canvas-local coordinate space.
* @param event Canvas mouse move event.
* Updates the smoothed look-at target from a page-level pointer.
* @param event Mouse or touch coordinates in the browser client space.
*/
const handleModelTurnHead = (event: Pick<MouseEvent, 'clientX' | 'clientY'>) => {
const updatePointerTarget = (event: Pick<MouseEvent, 'clientX' | 'clientY'>) => {
const point = toCubism2ViewPoint(canvas, event);
animator?.setPointerTarget(point.x, point.y);
void animator?.startMotionForPoint(point.x, point.y).catch((error: unknown) => {
console.warn('[KT Blog] Live2D interaction motion failed.', error);
});
};
/** Restores the source look-front behavior after the pointer leaves or is released. */
@ -126,16 +123,19 @@ export function createWebGLLive2DRenderer(canvas: HTMLCanvasElement): Live2DRend
return;
}
event.preventDefault();
handleModelTurnHead(event);
const point = toCubism2ViewPoint(canvas, event);
animator?.setPointerTarget(point.x, point.y);
void animator?.startMotionForPoint(point.x, point.y).catch((error: unknown) => {
console.warn('[KT Blog] Live2D interaction motion failed.', error);
});
};
/**
* Handles the source hover behavior, including hit-motion attempts.
* @param event Canvas mouse-move event.
* Preserves the WordPress widget's page-wide gaze tracking.
* @param event Page-level mouse-move event.
*/
const handleMouseMove = (event: MouseEvent) => {
event.preventDefault();
handleModelTurnHead(event);
updatePointerTarget(event);
};
/**
@ -150,7 +150,11 @@ export function createWebGLLive2DRenderer(canvas: HTMLCanvasElement): Live2DRend
const touch = event.touches[0];
if (touch) {
touchDragging = true;
handleModelTurnHead(touch);
const point = toCubism2ViewPoint(canvas, touch);
animator?.setPointerTarget(point.x, point.y);
void animator?.startMotionForPoint(point.x, point.y).catch((error: unknown) => {
console.warn('[KT Blog] Live2D interaction motion failed.', error);
});
}
};
@ -167,8 +171,7 @@ export function createWebGLLive2DRenderer(canvas: HTMLCanvasElement): Live2DRend
return;
}
event.preventDefault();
const point = toCubism2ViewPoint(canvas, touch);
animator?.setPointerTarget(point.x, point.y);
updatePointerTarget(touch);
};
/**
@ -192,20 +195,18 @@ export function createWebGLLive2DRenderer(canvas: HTMLCanvasElement): Live2DRend
activeTexture = null;
};
window.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseleave', handlePointerRelease);
canvas.addEventListener('mousedown', handleMouseDown);
canvas.addEventListener('mousemove', handleMouseMove);
canvas.addEventListener('mouseup', handlePointerRelease);
canvas.addEventListener('mouseout', handlePointerRelease);
canvas.addEventListener('touchstart', handleTouchStart, { passive: false });
canvas.addEventListener('touchmove', handleTouchMove, { passive: false });
canvas.addEventListener('touchend', handleTouchEnd, { passive: false });
return {
destroy() {
window.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseleave', handlePointerRelease);
canvas.removeEventListener('mousedown', handleMouseDown);
canvas.removeEventListener('mousemove', handleMouseMove);
canvas.removeEventListener('mouseup', handlePointerRelease);
canvas.removeEventListener('mouseout', handlePointerRelease);
canvas.removeEventListener('touchstart', handleTouchStart);
canvas.removeEventListener('touchmove', handleTouchMove);
canvas.removeEventListener('touchend', handleTouchEnd);