kt-blog-web/src/components/blog/live2d/runtime/webglLive2DRenderer.ts

303 lines
9.8 KiB
TypeScript

import { loadCubism2Core } from './cubism2CoreLoader';
import {
createCubism2ModelAnimator,
type Cubism2ModelAnimator,
} from './cubism2ModelAnimator';
import { configureCubism2ModelProjection } from './cubism2ModelProjection';
import { toCubism2ViewPoint } from './cubism2PointerCoordinates';
import type { Live2DCoreModel, Live2DRendererAdapter, Live2DResolvedState } from './live2dRuntimeTypes';
import { installCubism2WebGLTextureReleaseHook } from '../vendor/cubism2Core/compatibility/webglTextureRelease';
/**
* Creates the browser WebGL renderer used by the TypeScript Live2D runtime.
* @param canvas Canvas owned by the Blog Live2D widget.
* @returns Renderer adapter that applies model and texture state to Cubism2 core objects.
*/
export function createWebGLLive2DRenderer(canvas: HTMLCanvasElement): Live2DRendererAdapter {
let gl: WebGLRenderingContext | null = null;
let model: Live2DCoreModel | null = null;
let animator: Cubism2ModelAnimator | null = null;
let activeTexture: WebGLTexture | null = null;
let frame = 0;
let touchDragging = false;
/**
* Loads and displays one model state.
* @param state Resolved model and texture state.
*/
const applyState = async (state: Live2DResolvedState): Promise<void> => {
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);
nextModel.saveParam();
const nextAnimator = createCubism2ModelAnimator({
Live2DMotion: window.Live2DMotion!,
MotionQueueManager: window.MotionQueueManager!,
loadMotionBytes: fetchArrayBuffer,
settings: state.settings,
});
let nextTexture: WebGLTexture;
try {
nextTexture = await loadTexture(
context,
nextModel,
resolveAssetUrl(state.settings.baseUrl, state.settings.textures[state.textureIndex] || state.settings.textures[0] || ''),
);
} catch (error: unknown) {
nextAnimator.stop();
throw error;
}
releaseActiveTexture();
animator?.stop();
model = nextModel;
animator = nextAnimator;
activeTexture = nextTexture;
void nextAnimator.preloadMotionGroup('idle').catch((error: unknown) => {
console.warn('[KT Blog] Live2D idle motion preload failed.', error);
});
startLoop();
};
/**
* Resolves and initializes the WebGL context.
* @returns WebGL context for the Live2D core.
*/
const resolveContext = async (): Promise<WebGLRenderingContext> => {
if (gl) {
return gl;
}
await loadCubism2Core();
const context = canvas.getContext('webgl', { premultipliedAlpha: true })
|| canvas.getContext('experimental-webgl', { premultipliedAlpha: true });
if (!(context instanceof WebGLRenderingContext)) {
throw new Error('Live2D WebGL context is not available.');
}
installCubism2WebGLTextureReleaseHook(context);
window.Live2D?.setGL?.(context);
context.clearColor(0, 0, 0, 0);
gl = context;
return context;
};
/**
* Starts the draw loop once.
*/
const startLoop = () => {
if (frame) {
return;
}
/** Draws one source-ordered Cubism2 animation frame. */
const tick = () => {
frame = window.requestAnimationFrame(tick);
if (!gl || !model || !animator) {
return;
}
gl.clear(gl.COLOR_BUFFER_BIT);
animator.update(model);
model.draw();
};
frame = window.requestAnimationFrame(tick);
};
/**
* Tracks the mouse in the source canvas-local coordinate space.
* @param event Canvas mouse move event.
*/
const handleModelTurnHead = (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. */
const handlePointerRelease = () => {
animator?.setPointerTarget(0, 0);
};
/**
* Handles the source left-button press behavior.
* @param event Canvas mouse-down event.
*/
const handleMouseDown = (event: MouseEvent) => {
if (event.button !== 0) {
return;
}
event.preventDefault();
handleModelTurnHead(event);
};
/**
* Handles the source hover behavior, including hit-motion attempts.
* @param event Canvas mouse-move event.
*/
const handleMouseMove = (event: MouseEvent) => {
event.preventDefault();
handleModelTurnHead(event);
};
/**
* Starts source-compatible single-touch tracking and interaction.
* @param event Canvas touch-start event.
*/
const handleTouchStart = (event: TouchEvent) => {
if (event.touches.length !== 1) {
return;
}
event.preventDefault();
const touch = event.touches[0];
if (touch) {
touchDragging = true;
handleModelTurnHead(touch);
}
};
/**
* Follows a source-compatible single touch without repeatedly starting hit motions.
* @param event Canvas touch-move event.
*/
const handleTouchMove = (event: TouchEvent) => {
if (!touchDragging) {
return;
}
const touch = event.touches[0];
if (!touch) {
return;
}
event.preventDefault();
const point = toCubism2ViewPoint(canvas, touch);
animator?.setPointerTarget(point.x, point.y);
};
/**
* Ends source-compatible touch tracking and restores the front-facing target.
* @param event Canvas touch-end event.
*/
const handleTouchEnd = (event: TouchEvent) => {
event.preventDefault();
touchDragging = false;
handlePointerRelease();
};
/**
* Releases the WebGL texture currently owned by this renderer.
*/
const releaseActiveTexture = () => {
if (!gl || !activeTexture) {
return;
}
gl.deleteTexture(activeTexture);
activeTexture = null;
};
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() {
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);
if (frame) {
window.cancelAnimationFrame(frame);
frame = 0;
}
releaseActiveTexture();
animator?.stop();
animator = null;
model = null;
gl = null;
},
mount: applyState,
switchModel: applyState,
async switchTexture(state) {
if (!model || !gl) {
await applyState(state);
return;
}
const nextTexture = await loadTexture(
gl,
model,
resolveAssetUrl(state.settings.baseUrl, state.settings.textures[state.textureIndex] || state.settings.textures[0] || ''),
);
releaseActiveTexture();
activeTexture = nextTexture;
},
};
}
/**
* Fetches binary data used by Cubism2 model and motion loaders.
* @param url Asset URL.
* @returns ArrayBuffer response body.
*/
async function fetchArrayBuffer(url: string): Promise<ArrayBuffer> {
const response = await fetch(url, { credentials: 'same-origin' });
if (!response.ok) {
throw new Error(`Live2D asset request failed: ${response.status}`);
}
return response.arrayBuffer();
}
/**
* Loads a texture image into Live2D model texture slot zero.
* @param gl WebGL context.
* @param model Live2D core model.
* @param url Texture image URL.
* @returns WebGL texture now attached to the model.
*/
async function loadTexture(gl: WebGLRenderingContext, model: Live2DCoreModel, url: string): Promise<WebGLTexture> {
const image = await loadImage(url);
const texture = gl.createTexture();
if (!texture) {
throw new Error('Live2D WebGL texture creation failed.');
}
if (model.isPremultipliedAlpha?.() === false) {
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, 1);
}
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_NEAREST);
gl.generateMipmap(gl.TEXTURE_2D);
model.setTexture(0, texture);
return texture;
}
/**
* Loads an HTML image element for WebGL upload.
* @param url Texture URL.
* @returns Loaded image element.
*/
function loadImage(url: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error(`Live2D texture request failed: ${url}`));
image.src = url;
});
}
/**
* Resolves a relative model asset URL against its model settings location.
* @param baseUrl Settings directory URL.
* @param assetPath Relative asset path.
* @returns Browser-resolvable asset URL.
*/
function resolveAssetUrl(baseUrl: string, assetPath: string): string {
return new URL(assetPath, new URL(baseUrl, window.location.origin)).toString();
}