fix: 恢复 Live2D 模型投影矩阵

This commit is contained in:
sunlei 2026-07-15 18:38:42 +08:00
parent 5ee9dfc247
commit f769e7306c
4 changed files with 205 additions and 1 deletions

View File

@ -108,6 +108,38 @@ async function fulfillLocalBlogImageAsset(route: Route) {
})
}
/**
* Preserves WebGL drawing-buffer pixels so the E2E can inspect a completed rendered frame.
*/
function installPreservedWebGLDrawingBuffer(): void {
type CanvasContextGetter = (
this: HTMLCanvasElement,
contextId: string,
options?: Record<string, unknown>,
) => unknown
const originalGetContext = HTMLCanvasElement.prototype.getContext as unknown as CanvasContextGetter
const canvasPrototype = HTMLCanvasElement.prototype as unknown as {
getContext: CanvasContextGetter
}
/**
* Delegates canvas context creation while enabling pixel retention only for WebGL contexts.
* @param contextId Browser canvas context identifier.
* @param options Caller-provided context attributes.
* @returns Canvas rendering context returned by the browser.
*/
canvasPrototype.getContext = function getContextWithPreservedBuffer(
contextId: string,
options?: Record<string, unknown>,
): unknown {
const contextOptions =
contextId === 'webgl' || contextId === 'experimental-webgl'
? { ...options, preserveDrawingBuffer: true }
: options
return originalGetContext.call(this, contextId, contextOptions)
}
}
/**
* Routes the Pio/Tia MOC API to local release artifacts and records runtime console errors.
* @param page Playwright page that will open Blog Web.
@ -121,6 +153,7 @@ async function prepareLive2DPage(
scriptRequests: string[] = [],
assetRequests: string[] = [],
) {
await page.addInitScript(installPreservedWebGLDrawingBuffer)
await page.route('https://s3.kwitsukasa.top/images/*', fulfillLocalBlogImageAsset)
await page.route('**/api/blog/live2d/*/moc/**', fulfillLocalMocAsset)
page.on('request', (request) => {
@ -173,6 +206,18 @@ async function verifyDesktopWidgetParity({ page }: { page: Page }) {
await expect(widget).toBeVisible()
await expect(canvas).toHaveAttribute('width', '280')
await expect(canvas).toHaveAttribute('height', '250')
await expect
.poll(() => page.evaluate(() => localStorage.getItem('kt-blog-live2d:model')))
.toBe('pio')
await expect
.poll(() =>
page.evaluate(
() =>
document.querySelector<HTMLCanvasElement>('canvas#live2d')?.toDataURL('image/png')
.length ?? 0,
),
)
.toBeGreaterThan(5000)
const widgetBox = await widget.boundingBox()
expect(widgetBox).not.toBeNull()
expect(Math.round(widgetBox?.x ?? -1)).toBe(0)
@ -191,6 +236,9 @@ async function verifyDesktopWidgetParity({ page }: { page: Page }) {
await expect(modelPicker).toContainText('选择看板娘')
await modelPicker.locator('.kt-blog__live2d-picker-option', { hasText: 'Tia' }).click()
await expect(modelPicker).toHaveCount(0)
await expect
.poll(() => page.evaluate(() => localStorage.getItem('kt-blog-live2d:model')))
.toBe('tia')
await clickLive2DToolbarIcon(page, '.fui-eye')
const texturePicker = page.locator('.kt-blog__modal-host--open.kt-blog__live2d-picker-modal')
await expect(texturePicker).toContainText('选择服装')
@ -204,7 +252,7 @@ async function verifyDesktopWidgetParity({ page }: { page: Page }) {
.length ?? 0,
),
)
.toBeGreaterThan(1000)
.toBeGreaterThan(5000)
await clickLive2DToolbarIcon(page, '.fui-photo')
await expect(page.locator('.waifu-tips')).toContainText('保存')
await clickLive2DToolbarIcon(page, '.fui-info-circle')

View File

@ -0,0 +1,48 @@
import { describe, expect, it, vi } from 'vitest'
import { configureCubism2ModelProjection } from '../../components/blog/live2d/runtime/cubism2ModelProjection'
describe('Cubism2 model projection', () => {
it('applies the source L2D model and canvas projection matrix before drawing', () => {
const setMatrix = vi.fn()
configureCubism2ModelProjection(
{
getCanvasHeight: () => 2,
getCanvasWidth: () => 4,
setMatrix,
},
{
height: 250,
width: 280,
},
{
center_x: 0,
center_y: -0.05,
width: 2,
},
)
expect(setMatrix).toHaveBeenCalledOnce()
const matrix = setMatrix.mock.calls[0]?.[0] as Float32Array
expect(matrix).toBeInstanceOf(Float32Array)
expect(matrix[0]).toBeCloseTo(0.5)
expect(matrix[5]).toBeCloseTo(-0.56)
expect(matrix[12]).toBeCloseTo(-1)
expect(matrix[13]).toBeCloseTo(0.504)
expect(matrix[10]).toBe(1)
expect(matrix[15]).toBe(1)
})
it('rejects invalid model or canvas dimensions instead of drawing a transparent frame', () => {
const model = {
getCanvasHeight: () => 2,
getCanvasWidth: () => 0,
setMatrix: vi.fn(),
}
expect(() => configureCubism2ModelProjection(model, { height: 250, width: 280 })).toThrow(
'Cubism2 model and canvas dimensions must be positive.',
)
})
})

View File

@ -0,0 +1,106 @@
import type { Live2DCoreModel } from './live2dRuntimeTypes';
type Cubism2ProjectionModel = Pick<Live2DCoreModel, 'getCanvasHeight' | 'getCanvasWidth' | 'setMatrix'>;
type Cubism2ProjectionCanvas = Pick<HTMLCanvasElement, 'height' | 'width'>;
/**
* Applies the original Cubism2 `projection × view × modelMatrix` transform to a loaded model.
* @param model Loaded Cubism2 model that owns the WebGL draw matrix.
* @param canvas Runtime canvas whose aspect ratio defines the projection scale.
* @param layout Optional `index.json` layout fields applied in the original source order.
*/
export function configureCubism2ModelProjection(
model: Cubism2ProjectionModel,
canvas: Cubism2ProjectionCanvas,
layout?: Record<string, number>,
): void {
if (typeof model.setMatrix !== 'function') {
throw new Error('Cubism2 model does not expose setMatrix().');
}
model.setMatrix(
createCubism2ModelProjectionMatrix(
model.getCanvasWidth(),
model.getCanvasHeight(),
canvas.width,
canvas.height,
layout,
),
);
}
/**
* Reconstructs the source L2DModelMatrix, layout overrides, and canvas projection as one matrix.
* @param modelWidth Cubism2 logical model canvas width.
* @param modelHeight Cubism2 logical model canvas height.
* @param canvasWidth WebGL drawing-buffer width.
* @param canvasHeight WebGL drawing-buffer height.
* @param layout Optional model layout values from `index.json`.
* @returns Column-major matrix passed directly to `Live2DModelWebGL.setMatrix()`.
*/
function createCubism2ModelProjectionMatrix(
modelWidth: number,
modelHeight: number,
canvasWidth: number,
canvasHeight: number,
layout?: Record<string, number>,
): Float32Array {
if (![modelWidth, modelHeight, canvasWidth, canvasHeight].every((value) => Number.isFinite(value) && value > 0)) {
throw new Error('Cubism2 model and canvas dimensions must be positive.');
}
let scaleX = 2 / modelWidth;
let scaleY = -scaleX;
let translateX = -1;
let translateY = -(modelHeight * scaleY) / 2;
const width = readLayoutNumber(layout, 'width');
if (width !== undefined) {
scaleX = width / modelWidth;
scaleY = -scaleX;
}
const height = readLayoutNumber(layout, 'height');
if (height !== undefined) {
scaleX = height / modelHeight;
scaleY = -scaleX;
}
translateX = readLayoutNumber(layout, 'x') ?? translateX;
translateY = readLayoutNumber(layout, 'y') ?? translateY;
const centerX = readLayoutNumber(layout, 'center_x');
if (centerX !== undefined) {
translateX = centerX - (modelWidth * scaleX) / 2;
}
const centerY = readLayoutNumber(layout, 'center_y');
if (centerY !== undefined) {
translateY = centerY - (modelHeight * scaleY) / 2;
}
translateY = readLayoutNumber(layout, 'top') ?? translateY;
const bottom = readLayoutNumber(layout, 'bottom');
if (bottom !== undefined) {
translateY = bottom - modelHeight * scaleY;
}
translateX = readLayoutNumber(layout, 'left') ?? translateX;
const right = readLayoutNumber(layout, 'right');
if (right !== undefined) {
translateX = right - modelWidth * scaleX;
}
const projectionScaleY = canvasWidth / canvasHeight;
return new Float32Array([
scaleX, 0, 0, 0,
0, scaleY * projectionScaleY, 0, 0,
0, 0, 1, 0,
translateX, translateY * projectionScaleY, 0, 1,
]);
}
/**
* Reads one finite numeric layout value without coercing malformed external JSON.
* @param layout Optional raw layout map.
* @param key Cubism2 layout key read in source order.
* @returns Finite numeric value, or undefined when absent or invalid.
*/
function readLayoutNumber(layout: Record<string, number> | undefined, key: string): number | undefined {
const value = layout?.[key];
return Number.isFinite(value) ? value : undefined;
}

View File

@ -1,4 +1,5 @@
import { loadCubism2Core } from './cubism2CoreLoader';
import { configureCubism2ModelProjection } from './cubism2ModelProjection';
import type { Live2DCoreModel, Live2DRendererAdapter, Live2DResolvedState } from './live2dRuntimeTypes';
import { installCubism2WebGLTextureReleaseHook } from '../vendor/cubism2Core/compatibility/webglTextureRelease';
@ -30,6 +31,7 @@ export function createWebGLLive2DRenderer(canvas: HTMLCanvasElement): Live2DRend
const context = await resolveContext();
const modelBuffer = await fetchArrayBuffer(resolveAssetUrl(state.settings.baseUrl, state.settings.model));
const nextModel = window.Live2DModelWebGL!.loadModel(modelBuffer);
configureCubism2ModelProjection(nextModel, canvas, state.settings.layout);
const nextTexture = await loadTexture(
context,
nextModel,