From 235f0494059f56f853316fb9624d8bd3191f2535 Mon Sep 17 00:00:00 2001 From: hobokenchicken Date: Fri, 5 Jun 2026 16:07:42 -0400 Subject: [PATCH] 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 --- frontend/src/components/Live2DStage.tsx | 73 +++++++++++++++---------- frontend/src/components/MusicPlayer.tsx | 6 +- 2 files changed, 46 insertions(+), 33 deletions(-) diff --git a/frontend/src/components/Live2DStage.tsx b/frontend/src/components/Live2DStage.tsx index 4aaa0d4..3434677 100644 --- a/frontend/src/components/Live2DStage.tsx +++ b/frontend/src/components/Live2DStage.tsx @@ -51,6 +51,8 @@ export default function Live2DStage({ onKiraReady, onReady, outfit }: Props) { const canvasRef = useRef(null); const kiraModelRef = useRef(null); const clothTexIdxRef = useRef(-1); + const pixiAppRef = useRef(null); + const defaultOutfitRef = useRef(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 ( diff --git a/frontend/src/components/MusicPlayer.tsx b/frontend/src/components/MusicPlayer.tsx index 2b2c895..c0539ea 100644 --- a/frontend/src/components/MusicPlayer.tsx +++ b/frontend/src/components/MusicPlayer.tsx @@ -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 {