fix: 恢复 Live2D 模型投影矩阵
This commit is contained in:
parent
5ee9dfc247
commit
f769e7306c
@ -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')
|
||||
|
||||
48
src/__tests__/live2d/Cubism2ModelProjection.spec.ts
Normal file
48
src/__tests__/live2d/Cubism2ModelProjection.spec.ts
Normal 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.',
|
||||
)
|
||||
})
|
||||
})
|
||||
106
src/components/blog/live2d/runtime/cubism2ModelProjection.ts
Normal file
106
src/components/blog/live2d/runtime/cubism2ModelProjection.ts
Normal 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;
|
||||
}
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user