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 canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const kiraModelRef = useRef<any>(null);
|
const kiraModelRef = useRef<any>(null);
|
||||||
const clothTexIdxRef = useRef<number>(-1);
|
const clothTexIdxRef = useRef<number>(-1);
|
||||||
|
const pixiAppRef = useRef<any>(null);
|
||||||
|
const defaultOutfitRef = useRef<any>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let mounted = true;
|
let mounted = true;
|
||||||
@@ -81,6 +83,7 @@ export default function Live2DStage({ onKiraReady, onReady, outfit }: Props) {
|
|||||||
autoDensity: true,
|
autoDensity: true,
|
||||||
});
|
});
|
||||||
if (!mounted) { app.destroy(true); return; }
|
if (!mounted) { app.destroy(true); return; }
|
||||||
|
pixiAppRef.current = app;
|
||||||
|
|
||||||
const onResize = () => {
|
const onResize = () => {
|
||||||
app.renderer.resize(window.innerWidth, window.innerHeight);
|
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)
|
// Find the clothing texture index (texture_02 = the outfit sheet)
|
||||||
try {
|
try {
|
||||||
const core = (kiraModel as any).internalModel?.coreModel;
|
const textures = kiraModel.textures;
|
||||||
if (core) {
|
if (textures && textures.length >= 3) {
|
||||||
const drawCount = core.getDrawableCount();
|
clothTexIdxRef.current = 2; // texture_02 is the outfit
|
||||||
// texture_02 is the last texture, find which drawable uses it
|
// Save the default outfit texture so we can restore it
|
||||||
// We'll swap the PIXI texture directly on the model's textures array
|
defaultOutfitRef.current = textures[2];
|
||||||
const textures = (kiraModel as any).internalModel?.textures;
|
|
||||||
if (textures && textures.length >= 3) {
|
|
||||||
clothTexIdxRef.current = 2; // texture_02 is the outfit
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
@@ -149,50 +148,64 @@ export default function Live2DStage({ onKiraReady, onReady, outfit }: Props) {
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
mounted = false;
|
mounted = false;
|
||||||
|
pixiAppRef.current = null;
|
||||||
if (app) { app.destroy(true, { children: true }); }
|
if (app) { app.destroy(true, { children: true }); }
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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(() => {
|
useEffect(() => {
|
||||||
const model = kiraModelRef.current;
|
const model = kiraModelRef.current;
|
||||||
const texIdx = clothTexIdxRef.current;
|
const texIdx = clothTexIdxRef.current;
|
||||||
if (!model || texIdx < 0 || !outfit) return;
|
if (!model || texIdx < 0 || !outfit) return;
|
||||||
|
|
||||||
const outfitSrc = `/live2d/models/kira/outfits/${outfit}.png`;
|
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 {
|
try {
|
||||||
const textures = model.textures;
|
const renderer = pixiAppRef.current?.renderer;
|
||||||
if (!textures || !textures[texIdx]) return;
|
if (!renderer) { console.warn('[outfit] no renderer'); return; }
|
||||||
|
|
||||||
const PIXI = await import('pixi.js');
|
const gl: WebGLRenderingContext = renderer.gl || renderer.CONTEXT?.gl;
|
||||||
const newBase = new PIXI.BaseTexture(outfitSrc);
|
if (!gl) { console.warn('[outfit] no GL context'); return; }
|
||||||
|
|
||||||
const doSwap = () => {
|
// 1. Create a new WebGL texture from the outfit image
|
||||||
// Replace the entire Texture object (not just baseTexture)
|
const newGLTex = gl.createTexture();
|
||||||
const newTex = new PIXI.Texture(newBase);
|
if (!newGLTex) { console.warn('[outfit] failed to create GL texture'); return; }
|
||||||
textures[texIdx] = newTex;
|
|
||||||
|
|
||||||
// Invalidate GL texture cache so the renderer re-binds next frame
|
gl.bindTexture(gl.TEXTURE_2D, newGLTex);
|
||||||
const glCtxId = model.internalModel?.glContextID;
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||||
if (glCtxId !== undefined && newBase._glTextures) {
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||||
delete newBase._glTextures[glCtxId];
|
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) {
|
// 2. Inject directly into the Cubism renderer's texture map
|
||||||
doSwap();
|
// 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 {
|
} else {
|
||||||
newBase.once('loaded', doSwap);
|
console.warn('[outfit] no cubism renderer or bindTexture');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} 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]);
|
}, [outfit]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ interface Playlist {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const LOFI_PLAYLISTS: Playlist[] = [
|
const LOFI_PLAYLISTS: Playlist[] = [
|
||||||
{ id: 'lofi-girl', name: 'lofi hip hop radio', videoId: '7NOSDKb0HlU', icon: '🎧' },
|
{ id: 'lofi-girl', name: 'lofi hip hop radio', videoId: '7ccH8u8fj8Y', icon: '🎧' },
|
||||||
{ id: 'lofi-chill', name: 'Chill lofi', videoId: 'MCkTebktHVc', icon: '🎵' },
|
{ id: 'lofi-chill', name: 'Chill lofi', videoId: 'HFQibg2OJkU', icon: '🎵' },
|
||||||
{ id: 'lofi-synth', name: 'Synthwave lofi', videoId: 'KMXZF-K2mus', icon: '🌃' },
|
{ id: 'lofi-synth', name: 'Synthwave lofi', videoId: 'udGvUx70Q3U', icon: '🌃' },
|
||||||
];
|
];
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
Reference in New Issue
Block a user