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:
2026-06-05 16:07:42 -04:00
parent 705792a4cb
commit 235f049405
2 changed files with 46 additions and 33 deletions
+43 -30
View File
@@ -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 (
+3 -3
View File
@@ -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 {