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 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 (
+3 -3
View File
@@ -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 {