fix(outfit): direct GL texture injection into Cubism renderer
Bypasses PIXI texture pipeline entirely. Loads outfit PNG as Image, creates raw WebGL texture, and calls cubismRenderer.bindTexture() directly. Also updated lo-fi video IDs to confirmed working non-stream videos: - 7ccH8u8fj8Y: Lofi Girl best of 2025 - HFQibg2OJkU: Chillhop Spring 2025 - udGvUx70Q3U: Lofi Chilled Beats 12hr
This commit is contained in:
@@ -51,6 +51,8 @@ export default function Live2DStage({ onKiraReady, onReady, outfit }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const kiraModelRef = useRef<any>(null);
|
||||
const clothTexIdxRef = useRef<number>(-1);
|
||||
const pixiAppRef = useRef<any>(null);
|
||||
const defaultOutfitRef = useRef<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
@@ -81,6 +83,7 @@ export default function Live2DStage({ onKiraReady, onReady, outfit }: Props) {
|
||||
autoDensity: true,
|
||||
});
|
||||
if (!mounted) { app.destroy(true); return; }
|
||||
pixiAppRef.current = app;
|
||||
|
||||
const onResize = () => {
|
||||
app.renderer.resize(window.innerWidth, window.innerHeight);
|
||||
@@ -103,15 +106,11 @@ export default function Live2DStage({ onKiraReady, onReady, outfit }: Props) {
|
||||
|
||||
// Find the clothing texture index (texture_02 = the outfit sheet)
|
||||
try {
|
||||
const core = (kiraModel as any).internalModel?.coreModel;
|
||||
if (core) {
|
||||
const drawCount = core.getDrawableCount();
|
||||
// texture_02 is the last texture, find which drawable uses it
|
||||
// We'll swap the PIXI texture directly on the model's textures array
|
||||
const textures = (kiraModel as any).internalModel?.textures;
|
||||
if (textures && textures.length >= 3) {
|
||||
clothTexIdxRef.current = 2; // texture_02 is the outfit
|
||||
}
|
||||
const textures = kiraModel.textures;
|
||||
if (textures && textures.length >= 3) {
|
||||
clothTexIdxRef.current = 2; // texture_02 is the outfit
|
||||
// Save the default outfit texture so we can restore it
|
||||
defaultOutfitRef.current = textures[2];
|
||||
}
|
||||
} catch {}
|
||||
|
||||
@@ -149,50 +148,64 @@ export default function Live2DStage({ onKiraReady, onReady, outfit }: Props) {
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
pixiAppRef.current = null;
|
||||
if (app) { app.destroy(true, { children: true }); }
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Outfit swap effect — replaces the clothing texture (index 2) on the Live2D model
|
||||
// Outfit swap: load PNG → create WebGL texture → inject into Cubism renderer
|
||||
useEffect(() => {
|
||||
const model = kiraModelRef.current;
|
||||
const texIdx = clothTexIdxRef.current;
|
||||
if (!model || texIdx < 0 || !outfit) return;
|
||||
|
||||
const outfitSrc = `/live2d/models/kira/outfits/${outfit}.png`;
|
||||
let cancelled = false;
|
||||
|
||||
// Load the outfit image
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => {
|
||||
if (cancelled) return;
|
||||
|
||||
const swapTexture = async () => {
|
||||
try {
|
||||
const textures = model.textures;
|
||||
if (!textures || !textures[texIdx]) return;
|
||||
const renderer = pixiAppRef.current?.renderer;
|
||||
if (!renderer) { console.warn('[outfit] no renderer'); return; }
|
||||
|
||||
const PIXI = await import('pixi.js');
|
||||
const newBase = new PIXI.BaseTexture(outfitSrc);
|
||||
const gl: WebGLRenderingContext = renderer.gl || renderer.CONTEXT?.gl;
|
||||
if (!gl) { console.warn('[outfit] no GL context'); return; }
|
||||
|
||||
const doSwap = () => {
|
||||
// Replace the entire Texture object (not just baseTexture)
|
||||
const newTex = new PIXI.Texture(newBase);
|
||||
textures[texIdx] = newTex;
|
||||
// 1. Create a new WebGL texture from the outfit image
|
||||
const newGLTex = gl.createTexture();
|
||||
if (!newGLTex) { console.warn('[outfit] failed to create GL texture'); return; }
|
||||
|
||||
// Invalidate GL texture cache so the renderer re-binds next frame
|
||||
const glCtxId = model.internalModel?.glContextID;
|
||||
if (glCtxId !== undefined && newBase._glTextures) {
|
||||
delete newBase._glTextures[glCtxId];
|
||||
}
|
||||
};
|
||||
gl.bindTexture(gl.TEXTURE_2D, newGLTex);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
||||
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
|
||||
gl.bindTexture(gl.TEXTURE_2D, null);
|
||||
|
||||
if (newBase.valid) {
|
||||
doSwap();
|
||||
// 2. Inject directly into the Cubism renderer's texture map
|
||||
// bypassing PIXI entirely
|
||||
const cubismRenderer = model.internalModel?.renderer;
|
||||
if (cubismRenderer && typeof cubismRenderer.bindTexture === 'function') {
|
||||
cubismRenderer.bindTexture(texIdx, newGLTex);
|
||||
console.log(`[outfit] injected GL texture for idx=${texIdx}: ${outfit}`);
|
||||
} else {
|
||||
newBase.once('loaded', doSwap);
|
||||
console.warn('[outfit] no cubism renderer or bindTexture');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Live2DStage] outfit swap failed:', e);
|
||||
console.warn('[outfit] swap failed:', e);
|
||||
}
|
||||
};
|
||||
img.onerror = () => { console.warn(`[outfit] failed to load ${outfitSrc}`); };
|
||||
img.src = outfitSrc;
|
||||
|
||||
swapTexture();
|
||||
return () => { cancelled = true; };
|
||||
}, [outfit]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -8,9 +8,9 @@ interface Playlist {
|
||||
}
|
||||
|
||||
const LOFI_PLAYLISTS: Playlist[] = [
|
||||
{ id: 'lofi-girl', name: 'lofi hip hop radio', videoId: '7NOSDKb0HlU', icon: '🎧' },
|
||||
{ id: 'lofi-chill', name: 'Chill lofi', videoId: 'MCkTebktHVc', icon: '🎵' },
|
||||
{ id: 'lofi-synth', name: 'Synthwave lofi', videoId: 'KMXZF-K2mus', icon: '🌃' },
|
||||
{ id: 'lofi-girl', name: 'lofi hip hop radio', videoId: '7ccH8u8fj8Y', icon: '🎧' },
|
||||
{ id: 'lofi-chill', name: 'Chill lofi', videoId: 'HFQibg2OJkU', icon: '🎵' },
|
||||
{ id: 'lofi-synth', name: 'Synthwave lofi', videoId: 'udGvUx70Q3U', icon: '🌃' },
|
||||
];
|
||||
|
||||
declare global {
|
||||
|
||||
Reference in New Issue
Block a user