给NekoWebShow加载提速

为期三个月的实习结束了,准备迎来寒假了,我在想要不要做一些有意思的事情。受到Bhao小姐姐博客的启发,我萌生了想要重新装修博客的想法,主题也很明确——猫娘乐园主题。
但是,如果还是像我现在这样,用别人的主题改一改,个性化一下,或许还是太没意思了。于是乎,我就想看看能不能自己从零开始做一个主题。
真正驱动我做这件事的,是半年前我搞出来了那个NekoWebShow项目,我希望把巧克力的动态立绘直接放在博客首页,只有首页有这种持续的动画效果,其他页面就是静态展示,这样子的话就不会像现在那样打开我的网站后一直占用性能。
我希望可以做到只在首页占用一些性能展示动态立绘,而往下浏览查看其他内容的时候,就没有活动的元素了。一方面可以凸显重点,保持整套主题风格的连贯性,另一方面节省访客电脑的性能。
当时还是有点犹豫要不要搞,在Bhao小姐姐的群里问了一下,她坚定地回了一个字——“做!”
她随后说其实用框架的话就和搭积木差不多,不是很难,于是我下定决心,开干。
而今天完成的工作则算是这个主题正式开工之前的前置工作——优化NekoWebShow加载速度

为什么需要优化

尽管目前的猫娘展示网页运行正常,但是由于模型文件较大,达到了32MB或者64MB,会导致较长的加载时间,如果网速慢那更是雪上加霜。对于纯粹的猫娘展示网页还是可以接受,但是对于个人博客那就是致命的——访客可能会嫌打开太久离开。所以说,这个是必须要优化的。
还有一点是,虽然这些专有格式体积庞大,但是转换前的文件却不算大——3-4MB左右的png纹理文件+3MB左右的json定义文件,很明显应该是有办法可以压缩下来的。

优化传输的探索——碰壁asm.js

一开始,我是希望逆向JS,顺着模型文件的传输路径去找到对应的处理代码,然后求助于AI解决。但是,可悲的是,我千辛万苦挖了半个小时,结果挖出来一段奇怪的js代码,例如:

// EMSCRIPTEN_END_FUNCS
var vd=[uB,Be,Ke,Fp,Iz,Pz,fA,uB];var wd=[vB,$e,df,ff,hf,eg,jg,lg];var xd=[wB,re,te,Ee,Fe,Qe,Re,We,Xe,Lf,bg,cg,dg,gg,wv,xg,Ji,Gp,yg,zg,Ne,Ag,Iu,Cg,Ki,Dg,Np,Hg,Qp,Rp,Li,an,bn,un,vn,Ln,Mn,Sn,Tn,bo,co,jo,ko,Co,Do,Xo,Yo,lp,mp,vp,wp,hz,Dp,Ep,Op,Ju,xv,Ew,Fw,Cz,Dz,Ez,Fz,Nz,Xz,Yz,$z,bA,dA,ly,Tz,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB];var yd=[xB,ch];var zd=[yB,we,ye,Ce,Ge,He,Ie,Ve,Ze,bf,uf,Xf,hg,ng,zv,Ip];var Ad=[zB,Xg,vi,zB];var Bd=[AB,ue,ve,vg,xe,ze,De,Se,Te,wg,Ue,Ye,_e,cf,rf,vf,xf,Mf,Of,Qf,ag,fg,ig,og,pg,rg,Hp,gz,gy,Zz,sh,th,uh,Qg,oh,qh,Jh,Ch,Dh,Fh,Eh,xi,ci,zi,yh,xh,Bh,Bi,zh,_h,$h,wh,AB,AB,AB,AB,AB,AB,AB,AB,AB,AB,AB,AB];var Cd=[BB,jh];var Dd=[CB,Yf];var Ed=[DB,hi];var Fd=[EB,qf,_f,$f];var Gd=[FB,gi];var Hd=[GB,Gf];var Id=[HB,mf,of,sf,If,HB,HB,HB];var Jd=[IB,bh];var Kd=[JB,fh,wi,JB];var Ld=[KB,ih];var Md=[LB,Af,Eg,Mi,hy,iy,ny,Ny,Gz,aA,cA,Ng,Rg,Yg,Zg,_g,di,ah,Wh,LB,LB,LB,LB,LB,LB,LB,LB,LB,LB,LB,LB,LB];var Nd=[MB,ui];var Od=[NB,Wg,li,NB];var Pd=[OB,Hz,Oz,eA];var Qd=[PB,Hh,Ih,PB];var Rd=[QB,Je,lf,Vf,Bg,Pp,QB,QB];var Sd=[RB,Kf];var Td=[SB,af,ef,gf,jf,nf,pf,tf,kg,mg,qg,Jf,Gi,Hi,Ig,Sp,Kh,SB,SB,SB,SB,SB,SB,SB,SB,SB,SB,SB,SB,SB,SB,SB];var Ud=[TB,kf];var Vd=[UB,Ci,ji,ni,fi,ki,ti,ri];var Wd=[VB,Cf];var Xd=[WB,gh];var Yd=[XB,yi];var Zd=[YB,pi,oi,ii,mi,YB,YB,YB];var _d=[ZB,yf,zf,Nf,Pf,Rf,Sf,Tf,Wf,sg,yv,Fg,Ni,mh,Og,Pg,nh,Zh,Gh,ei,Oh,Nh,ai,Ai,bi,Yh,Ah,Qh,Ph,kh,Mh,qi,si,qr,ZB,ZB,ZB,ZB,ZB,ZB,ZB,ZB,ZB,ZB,ZB,ZB,ZB,ZB,ZB,ZB,ZB,ZB,ZB,ZB,ZB,ZB,ZB,ZB,ZB,ZB,ZB,ZB,ZB,ZB];var $d=[_B,Jp,Sg,_B];var ae=[$B,Bf,Ff,Vg,Xh,Vh,$B,$B];var be=[aC,Ef];var ce=[bC,wf,Df,Uf,Zf,tg,Rh,Th,Sh,Uh,lh,Lh,bC,bC,bC,bC];var de=[cC,Ug];var ee=[dC,rh,ph,dC];var fe=[eC,Tg];var ge=[fC,vh,$g,dh];var he=[gC,eh,hh,gC];var ie=[hC,iC,zz,Yq,Sz,hC,hC,hC];var je=[jC,Ae,Le,Hf,Jz,Qz,gA,jC];return{_EmotePlayer_Update:Ci,_EmotePlayer_SetStereovisionVolume:ti,_EmotePlayer_FadeInTimeline:Hh,_EmotePlayer_GetVariableLabelAt:Yh,_bitshift64Lshr:sA,_EmotePlayer_SetColor:gi,_EmotePlayer_GetMainTimelineLabelAt:Oh,_EmotePlayer_SetOuterForce:li,_EmotePlayer_GetState:Rh,_EmotePlayer_StopTimeline:Ai,_EmoteDevice_DeleteEmoteTexture2:oh,_memcpy:tA,_EmotePlayer_SetMeshDivisionRatio:ki,_EmotePlayer_SetVariable:vi,_EmotePlayer_Initialize:Zh,_EmotePlayer_CountPlayingTimelines:zh,___udivmoddi4:xA,_EmotePlayer_Pass:ci,___cxa_can_catch:mA,_EmotePlayer_GetCharaProfile:Lh,_EmotePlayer_SetVariableDiff:wi,_free:hz,_EmoteDevice_Initialize:rh,_main:Di,_EmotePlayer_SetStereovisionRenderScreen:si,_EmotePlayer_GetCharaHeight:Kh,_EmotePlayer_IsCharaProfileAvailable:$h,_EmotePlayer_SetOuterRot:mi,___cxa_is_pointer_type:nA,_llvm_cttz_i32:wA,_EmotePlayer_GetDiffTimelineLabelAt:Nh,_EmotePlayer_IsTimelinePlaying:bi,_EmotePlayer_Step:zi,_EmotePlayer_SetGrayscale:ii,_EmotePlayer_GetVariableFrameLabelAt:Wh,_EmotePlayer_SetTimelineBlendRatio:ui,_EmoteDevice_CreateEmoteTexture2:nh,_EmotePlayer_SetScale:pi,_EmoteDevice_ChangeFrameBufferSize:mh,_EmotePlayer_DrawToTexture:Fh,_EmotePlayer_CountDiffTimelines:xh,_EmotePlayer_PlayTimeline:di,_EmotePlayer_GetVariableDiff:Vh,_EmotePlayer_SetBustScale:fi,_roundf:rA,_EmotePlayer_CountVariableFrameAt:Ah,_EmotePlayer_SetRot:oi,_EmotePlayer_GetTimelineBlendRatio:Sh,_EmotePlayer_IsAnimating:_h,_EmotePlayer_SetStereovisionEnabled:qi,_EmotePlayer_Draw:Dh,_sbrk:uA,_memset:zA,_EmotePlayer_Finish:Jh,_EmotePlayer_FadeOutTimeline:Ih,_EmotePlayer_DrawToScreen:Eh,_i64Subtract:pA,_EmotePlayer_Skip:xi,_EmotePlayer_CountMainTimelines:yh,___muldsi3:BA,_EmotePlayer_GetCharaProfileLabelAt:Mh,_EmotePlayer_GetPlayingTimelineFlagsAt:Ph,_EmotePlayer_SetAsOriginalScale:ei,_malloc:gz,_EmotePlayer_GetPlayingTimelineLabelAt:Qh,_EmotePlayer_StopWind:Bi,_EmoteDevice_SetMaskRegionClipping:th,_EmotePlayer_AttachRenderTexture:vh,_EmotePlayer_DrawToTexture2:Gh,___udivdi3:AA,_EmotePlayer_IsLoopTimeline:ai,_bitshift64Shl:DA,_EmotePlayer_GetVariableFrameValueAt:Xh,_EmotePlayer_SetHairScale:ji,_EmotePlayer_StartWind:yi,_EmotePlayer_SetStereovisionParallaxRatio:ri,___muldi3:CA,_EmotePlayer_CountVariables:Bh,___uremdi3:yA,_EmotePlayer_SetPartsScale:ni,_i64Add:qA,_pthread_self:EA,_EmoteDevice_SetProtectTranslucentTextureColor:uh,___getTypeName:fy,___errno_location:ky,_EmotePlayer_SetCoord:hi,_EmotePlayer_GetVariable:Uh,_memmove:vA,_EmotePlayer_CountCharaProfiles:wh,_EmoteDevice_Finish:ph,_EmoteDevice_GetEmoteTexture2TexId:qh,_EmotePlayer_DetachRenderTexture:Ch,_EmoteDevice_SetMaskMode:sh,_EmotePlayer_GetTimelineTotalFrameCount:Th,__GLOBAL__sub_I_emsbind_cpp:Lg,__GLOBAL__sub_I_bind_cpp:dy,runPostSets:oA,stackAlloc:ke,stackSave:le,stackRestore:me,establishStackSpace:ne,setThrew:oe,setTempRet0:pe,getTempRet0:qe,dynCall_viiiii:FA,dynCall_vid:GA,dynCall_vi:HA,dynCall_iiiidd:IA,dynCall_vii:JA,dynCall_iiiddd:KA,dynCall_ii:LA,dynCall_iiiddddd:MA,dynCall_viidddi:NA,dynCall_iidddd:OA,dynCall_viidd:PA,dynCall_iidddddd:QA,dynCall_viidddd:RA,dynCall_viddd:SA,dynCall_iiiidddi:TA,dynCall_iiiiddd:UA,dynCall_iiiidddd:VA,dynCall_iiii:WA,dynCall_iiidddi:XA,dynCall_iiidddd:YA,dynCall_viiiiii:ZA,dynCall_iiidd:_A,dynCall_viii:$A,dynCall_viddddd:aB,dynCall_di:bB,dynCall_vidddd:cB,dynCall_iid:dB,dynCall_viiddd:eB,dynCall_iiiiiddd:fB,dynCall_iiddddd:gB,dynCall_iiddd:hB,dynCall_iii:iB,dynCall_iiiiii:jB,dynCall_diii:kB,dynCall_viiiddd:lB,dynCall_dii:mB,dynCall_iiidddddd:nB,dynCall_i:oB,dynCall_iiid:pB,dynCall_iiiii:qB,dynCall_diiii:rB,dynCall_v:sB,dynCall_viiii:tB}})

我寻思着是谁能写出来这么抽象的js代码,后面经过和LLM近一个多小时的拉扯和了解,得知这个js是用C或者C++通过Emscriptenn编译而来,属于asm.js,或更准确地说,是Emscripten生成的胶水代码。需要修改实现新功能,难度不亚于汇编,因为整个代码高度耦合,是在用js去模拟C的运行环境。并且多次好心劝退我不要尝试去修改这部分的代码,即使这些代码确实都是合规的JS代码。
我第一次意识到,有些JS代码虽然语法合法,却因高度混淆和底层模拟,几乎无法安全修改或逆向理解。然后也意识到修改功能实现这个是死胡同了,只能另寻他法。

优化传输的探索——轮子套轮子

既然已经知道这段抽象的js代码不好惹,那就曲线救国。
因为我发现这些模型文件,是经过处理后变大的,而且无论原本的png大小是多少,只要是4096x2048的纹理,处理后就是32MB,只要是4096x4096的纹理,处理后就是64MB。我就意识到这个文件里面肯定包含了很多空数据,应该使用压缩工具可以很好压缩。实验后事实的确如此——压缩后的.zip大概是3-4MB左右的大小,甚至2MB。
于是,我的思路就转换过来了:服务器发送压缩后的文件,浏览器前端解压,然后把解压后的文件传给后续的js处理,这样就不需要动那一大堆如同狗屎的js了。

为了解决这个问题,我打算引入fflate.jsfflate.js是一个js库,可以支持在前端的浏览器内进行压缩文件解压的操作,这个库也是LLM给我推荐的,后面试了一下确实很不错。
首先是把js下载回来,并在html中调用:

<script type="text/JavaScript" src="fflate.js" charset="UTF-8"></script>

接下来是修改代码逻辑:

    //增加前端解压zip功能,减少数据传输量
    try {
        // 1. Fetch the ZIP file
        const resp = await fetch(zipUrl);
        if (!resp.ok) throw new Error(`Failed to load ${zipUrl}`);
        const zipData = new Uint8Array(await resp.arrayBuffer());

        // 2. Decompress ZIP using fflate
        const files = await new Promise((resolve, reject) => {
            fflate.unzip(zipData, (err, unzipped) => {
                if (err) {
                    console.error("ZIP decompression error:", err);
                    reject(err);
                } else {
                    resolve(unzipped);
                }
            });
        });

        // 3. Find the .psb model file (assume only one .psb file)
        const binFileName = Object.keys(files).find(name => name.endsWith('.psb'));
        if (!binFileName) {
            throw new Error("No .psb file found in ZIP");
        }

        const modelData = files[binFileName];

        // 4. Create blob URL for EmotePlayer
        const blob = new Blob([modelData], { type: 'application/octet-stream' });
        const fakeUrl = URL.createObjectURL(blob);

        // 5. Load model
        await player.promiseLoadDataFromURL(fakeUrl);

        // ===== Model loaded successfully =====
        document.getElementById('loading').innerHTML = "Done!";
        setTimeout(() => {
            document.getElementById('loading').style.visibility = "hidden";
        }, 1000);

这部分的代码,改为获取zip压缩文件,解压后放在浏览器的blob上,然后给后续的player.promiseLoadDataFromURL()进行调用。这样一来,就可以极大的降低传输文件的流量消耗和等待时间。
不过,需要注意的是,创建压缩文件的时候,需要内部只有一个文件,并且命名符合规范,采用的压缩算法需要是DEFLATE,否则会不支持。

批量处理pure.psb文件

因为角色模型文件有点多,一个一个手动压缩比较花时间,于是我就用bash脚本批量处理:

#!/bin/bash

# 遍历当前目录下所有 .pure.psb 文件
while IFS= read -r -d '' file; do
    # 构造目标 ZIP 文件名
    zip_file="${file}.zip"

    # 如果 ZIP 已存在,跳过(可选:如需强制覆盖,请删除此 if 块)
    if [[ -e "$zip_file" ]]; then
        echo "Skip: $zip_file already exists."
        continue
    fi

    echo "Compressing: $file -> $zip_file"
    
    # 使用 zip 最大压缩级别 (-9),且只存一个文件(-j 可选,但建议保留路径结构)
    # 注意:这里不加 -j,保留文件名;若你希望 ZIP 内部无路径,可加 -j
    zip -9 -q "$zip_file" "$file"

    if [[ $? -eq 0 ]]; then
        echo "✅ Success: $zip_file created."
    else
        echo "❌ Failed to compress: $file"
    fi

done < <(find . -maxdepth 1 -type f -name "*.pure.psb" -print0)

echo "All done!"

保存为compress_psb.sh放在模型文件夹内,直接执行就可以全部完成压缩了。

目前以上部分的代码改动和优化,已经同步到了NekoWebShow,访问的加载速度应该也可以变快了。

番外:增加动画暂停功能

接下来这个功能就是为了服务我博客主题构建的了——动画暂停
因为我设想是动态的E-Mote立绘只放在首页,而浏览文章目录的时候不会展示,会滑上去看不到,这时如果还是继续运行就有点太浪费性能了。于是,我希望动画可以按需暂停和恢复,而且我想做的页面是一下子就滑掉的,所以只要把控好时机暂停和恢复,对用户观看页面就基本无感。
感谢LLM,在把代码给LLM看过后,它说原始代码虽然没有这个功能,但是实现起来不难,只需要在加载播放器完成后执行下面的代码:

// monkey-patch 在加载完 EmotePlayer 后执行,实现动画按需暂停,节省性能
// 暂停命令:EmotePlayer.device.pause();  恢复命令:EmotePlayer.device.resume();
if (EmotePlayer.device) {
    EmotePlayer.device._originalKick = EmotePlayer.device.kickAnimation;
    EmotePlayer.device.isManuallyPaused = false;

    EmotePlayer.device.pause = function() {
        this.isManuallyPaused = true;
        if (this.animating) {
            this.animating = false;
            cancelAnimationFrame(this.requestId);
        }
    };

    EmotePlayer.device.resume = function() {
        this.isManuallyPaused = false;
        this._originalKick(); // 触发原逻辑
    };

    // 重写 kickAnimation,使其尊重手动暂停
    EmotePlayer.device.kickAnimation = function() {
        if (this.isManuallyPaused) return;
        this._originalKick();
    };
}
// monkey-patch END

将这段代码插入到run()函数后面即可实现。
这时,在控制台执行

EmotePlayer.device.pause();

即可实现动画暂停,CPU利用率回到0%
需要恢复时,执行

EmotePlayer.device.resume();

即可恢复动态立绘渲染。