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

This commit is contained in:
sunlei 2026-07-16 00:41:09 +08:00
parent efd642c2ae
commit 3697014831
3 changed files with 105 additions and 67 deletions

View File

@ -1,67 +1,77 @@
import { describe, expect, it } from 'vitest'
import { toCubism2ViewPoint } from '../../components/blog/live2d/runtime/cubism2PointerCoordinates'
import {
toCubism2ModelPoint,
toCubism2PageTarget,
} from '../../components/blog/live2d/runtime/cubism2PointerCoordinates'
describe('Cubism2 pointer coordinates', () => {
it('preserves source coordinates for points inside the canvas', () => {
const canvas = {
getBoundingClientRect: () => ({
bottom: 270,
height: 250,
left: 10,
right: 290,
top: 20,
width: 280,
x: 10,
y: 20,
toJSON: () => ({}),
}),
height: 500,
width: 560,
}
const canvas = {
getBoundingClientRect: () => ({
bottom: 720,
height: 250,
left: 0,
right: 280,
top: 470,
width: 280,
x: 0,
y: 470,
toJSON: () => ({}),
}),
height: 500,
width: 560,
}
expect(toCubism2ViewPoint(canvas, { clientX: 10, clientY: 20 })).toEqual({
it('preserves source canvas-local coordinates for model hit testing', () => {
expect(toCubism2ModelPoint(canvas, { clientX: 0, clientY: 470 })).toEqual({
x: -1,
y: 500 / 560,
})
expect(toCubism2ViewPoint(canvas, { clientX: 290, clientY: 270 })).toEqual({
expect(toCubism2ModelPoint(canvas, { clientX: 280, clientY: 720 })).toEqual({
x: 1,
y: -500 / 560,
})
expect(toCubism2ViewPoint(canvas, { clientX: 150, clientY: 145 })).toEqual({
expect(toCubism2ModelPoint(canvas, { clientX: 140, clientY: 595 })).toEqual({
x: 0,
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,
}
it('uses the whole viewport as a continuous page-level look-at range', () => {
const viewport = { innerHeight: 720, innerWidth: 1280 }
expect(toCubism2ViewPoint(canvas, { clientX: 1_000, clientY: 145 })).toEqual({
expect(toCubism2PageTarget(canvas, viewport, { clientX: 140, clientY: 595 })).toEqual({
x: 0,
y: 0,
})
expect(toCubism2PageTarget(canvas, viewport, { clientX: 0, clientY: 595 })).toEqual({
x: -1,
y: 0,
})
expect(toCubism2PageTarget(canvas, viewport, { clientX: 1280, clientY: 595 })).toEqual({
x: 1,
y: 0,
})
expect(toCubism2ViewPoint(canvas, { clientX: 150, clientY: -1_000 })).toEqual({
expect(toCubism2PageTarget(canvas, viewport, { clientX: 140, clientY: 0 })).toEqual({
x: 0,
y: 500 / 560,
})
expect(toCubism2ViewPoint(canvas, { clientX: 710, clientY: -355 })).toEqual({
x: 1,
y: 500 / 560,
expect(toCubism2PageTarget(canvas, viewport, { clientX: 140, clientY: 720 })).toEqual({
x: 0,
y: -500 / 560,
})
})
it('retains page-level distance after the pointer leaves the canvas', () => {
const viewport = { innerHeight: 720, innerWidth: 1280 }
const near = toCubism2PageTarget(canvas, viewport, { clientX: 300, clientY: 595 })
const middle = toCubism2PageTarget(canvas, viewport, { clientX: 640, clientY: 595 })
const far = toCubism2PageTarget(canvas, viewport, { clientX: 1200, clientY: 595 })
expect(near.x).toBeCloseTo(160 / 1140)
expect(middle.x).toBeCloseTo(500 / 1140)
expect(far.x).toBeCloseTo(1060 / 1140)
expect(near.x).toBeLessThan(middle.x)
expect(middle.x).toBeLessThan(far.x)
})
})

View File

@ -4,16 +4,41 @@ export interface Cubism2ViewPoint {
}
type Cubism2PointerCanvas = Pick<HTMLCanvasElement, 'height' | 'width' | 'getBoundingClientRect'>;
type Cubism2ClientPoint = Pick<MouseEvent, 'clientX' | 'clientY'>;
type Cubism2PointerViewport = Pick<Window, 'innerHeight' | 'innerWidth'>;
/**
* 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.
* Converts a canvas interaction into the source Cubism2 model-view coordinates used for hit testing.
* @param canvas Canvas whose bounds and drawing-buffer dimensions define the model-view transform.
* @param point Browser client coordinates from a mouse or touch event.
* @returns View point projected from the model center to the canvas boundary when needed.
* @returns Source view point where both axes use the canvas width as the scale denominator.
*/
export function toCubism2ViewPoint(
export function toCubism2ModelPoint(
canvas: Cubism2PointerCanvas,
point: Cubism2ClientPoint,
): Cubism2ViewPoint {
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 coordinateScale = Math.max(canvas.width, 1);
return {
x: (deviceX - canvas.width / 2) * 2 / coordinateScale,
y: (canvas.height / 2 - deviceY) * 2 / coordinateScale,
};
}
/**
* Maps page-wide pointer movement continuously into the Cubism2 look-at range around the model center.
* @param canvas Canvas whose center is the neutral look-at origin and whose aspect ratio limits vertical motion.
* @param viewport Browser viewport whose edges represent the maximum look-at targets in each direction.
* @param point Browser client coordinates from a page-level pointer event.
* @returns Bounded look-at target that retains distance across the whole visible page.
*/
export function toCubism2PageTarget(
canvas: Cubism2PointerCanvas,
viewport: Cubism2PointerViewport,
point: Cubism2ClientPoint,
): Cubism2ViewPoint {
const bounds = canvas.getBoundingClientRect();
const cssWidth = Math.max(bounds.width, 1);
@ -22,20 +47,20 @@ export function toCubism2ViewPoint(
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);
const horizontalDistanceToViewportEdge = Math.max(
deltaX < 0 ? centerX : viewport.innerWidth - centerX,
1,
);
const verticalDistanceToViewportEdge = Math.max(
deltaY < 0 ? centerY : viewport.innerHeight - centerY,
1,
);
const verticalRange = canvas.height / Math.max(canvas.width, 1);
const normalizedY = deltaY === 0
? 0
: -deltaY / verticalDistanceToViewportEdge * verticalRange;
return {
x: (deviceX - canvas.width / 2) * 2 / coordinateScale,
y: (canvas.height / 2 - deviceY) * 2 / coordinateScale,
x: Math.max(-1, Math.min(1, deltaX / horizontalDistanceToViewportEdge)),
y: Math.max(-verticalRange, Math.min(verticalRange, normalizedY)),
};
}

View File

@ -4,7 +4,10 @@ import {
type Cubism2ModelAnimator,
} from './cubism2ModelAnimator';
import { configureCubism2ModelProjection } from './cubism2ModelProjection';
import { toCubism2ViewPoint } from './cubism2PointerCoordinates';
import {
toCubism2ModelPoint,
toCubism2PageTarget,
} from './cubism2PointerCoordinates';
import type { Live2DCoreModel, Live2DRendererAdapter, Live2DResolvedState } from './live2dRuntimeTypes';
import { installCubism2WebGLTextureReleaseHook } from '../vendor/cubism2Core/compatibility/webglTextureRelease';
@ -104,8 +107,8 @@ export function createWebGLLive2DRenderer(canvas: HTMLCanvasElement): Live2DRend
* Updates the smoothed look-at target from a page-level pointer.
* @param event Mouse or touch coordinates in the browser client space.
*/
const updatePointerTarget = (event: Pick<MouseEvent, 'clientX' | 'clientY'>) => {
const point = toCubism2ViewPoint(canvas, event);
const updatePagePointerTarget = (event: Pick<MouseEvent, 'clientX' | 'clientY'>) => {
const point = toCubism2PageTarget(canvas, window, event);
animator?.setPointerTarget(point.x, point.y);
};
@ -123,8 +126,7 @@ export function createWebGLLive2DRenderer(canvas: HTMLCanvasElement): Live2DRend
return;
}
event.preventDefault();
const point = toCubism2ViewPoint(canvas, event);
animator?.setPointerTarget(point.x, point.y);
const point = toCubism2ModelPoint(canvas, event);
void animator?.startMotionForPoint(point.x, point.y).catch((error: unknown) => {
console.warn('[KT Blog] Live2D interaction motion failed.', error);
});
@ -135,7 +137,7 @@ export function createWebGLLive2DRenderer(canvas: HTMLCanvasElement): Live2DRend
* @param event Page-level mouse-move event.
*/
const handleMouseMove = (event: MouseEvent) => {
updatePointerTarget(event);
updatePagePointerTarget(event);
};
/**
@ -150,7 +152,7 @@ export function createWebGLLive2DRenderer(canvas: HTMLCanvasElement): Live2DRend
const touch = event.touches[0];
if (touch) {
touchDragging = true;
const point = toCubism2ViewPoint(canvas, touch);
const point = toCubism2ModelPoint(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);
@ -171,7 +173,8 @@ export function createWebGLLive2DRenderer(canvas: HTMLCanvasElement): Live2DRend
return;
}
event.preventDefault();
updatePointerTarget(touch);
const point = toCubism2ModelPoint(canvas, touch);
animator?.setPointerTarget(point.x, point.y);
};
/**