mcuo's picture
download
raw
50.1 kB
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Anime2.5DRig — PSDドロップで動くアバター</title>
<style>
:root{--bg:#14151c;--panel:#1d1f2a;--line:#2c2f3e;--txt:#dfe2ee;--sub:#8d93ad;--acc:#7aa2ff;}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--txt);font-family:"Hiragino Sans","Yu Gothic UI",system-ui,sans-serif;height:100vh;display:flex;flex-direction:column;overflow:hidden}
header{padding:10px 16px;border-bottom:1px solid var(--line);display:flex;align-items:baseline;gap:12px;flex-wrap:wrap}
header h1{font-size:15px;font-weight:600}
header span{font-size:11px;color:var(--sub)}
#main{flex:1;display:flex;min-height:0}
#stage{flex:1;display:flex;align-items:center;justify-content:center;min-width:0;position:relative}
#stage.checker{background:repeating-conic-gradient(#23242e 0 25%,#1a1b23 0 50%) 0 0/24px 24px}
#stage.green{background:#00b140}
canvas{max-width:100%;max-height:100%;display:block}
#fps{position:absolute;left:10px;top:8px;font-size:11px;color:var(--sub)}
#rigInfo{position:absolute;left:10px;bottom:8px;font-size:10px;color:var(--sub);max-width:60%;line-height:1.5}
#drop{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:14px;background:rgba(20,21,28,.88);z-index:5;text-align:center;padding:20px}
#drop.hidden{display:none}
#drop h2{font-size:18px;font-weight:600}
#drop p{font-size:12px;color:var(--sub);line-height:1.8}
#drop.dragover{outline:3px dashed var(--acc);outline-offset:-16px}
#panel{width:300px;background:var(--panel);border-left:1px solid var(--line);overflow-y:auto;padding:14px 16px 40px;flex-shrink:0}
.sec{margin-bottom:18px}
.sec h2{font-size:11px;letter-spacing:.08em;color:var(--sub);text-transform:uppercase;margin-bottom:8px;border-bottom:1px solid var(--line);padding-bottom:4px}
.row{display:flex;align-items:center;gap:8px;margin:7px 0}
.row label{font-size:12px;width:86px;flex-shrink:0}
.row input[type=range]{flex:1;accent-color:var(--acc);height:14px}
.row .val{font-size:10px;color:var(--sub);width:34px;text-align:right}
.toggles{display:flex;flex-wrap:wrap;gap:6px}
.tg{font-size:12px;padding:5px 10px;border:1px solid var(--line);border-radius:20px;cursor:pointer;user-select:none;color:var(--sub)}
.tg.on{border-color:var(--acc);color:var(--acc);background:rgba(122,162,255,.1)}
.btns{display:flex;gap:6px;flex-wrap:wrap}
.btn{font-size:12px;padding:6px 14px;background:#262939;border:1px solid var(--line);border-radius:6px;color:var(--txt);cursor:pointer}
.btn:hover{border-color:var(--acc)}
.btn.primary{background:var(--acc);color:#10121a;font-weight:600;border-color:var(--acc)}
#layers{font-size:11px;color:var(--sub);line-height:1.7}
#layers b{color:var(--txt);font-weight:500}
.tag{font-size:9px;border:1px solid var(--line);border-radius:4px;padding:0 4px;margin-left:4px;color:var(--acc)}
/* ---- monochrome + deep red theme ---- */
:root{--bg:#0d0d0f;--panel:#151517;--line:#29292e;--txt:#ececee;--sub:#87878f;--acc:#a01030;--acc2:#d43b55;}
body{background:radial-gradient(1200px 600px at 70% -10%, rgba(160,16,48,.09), transparent 60%), var(--bg);}
header{position:relative}
header::after{content:'';position:absolute;left:0;bottom:-1px;width:140px;height:2px;
background:linear-gradient(90deg,var(--acc),transparent);}
header h1{letter-spacing:.05em}
.tg,.btn,input[type=range],.sec h2{transition:all .18s ease}
.tg:hover{border-color:var(--acc);color:var(--acc2)}
.tg.on{border-color:var(--acc);color:var(--acc2);background:rgba(160,16,48,.14);box-shadow:inset 0 0 8px rgba(160,16,48,.25)}
.btn{background:#1c1c1f}
.btn:hover{border-color:var(--acc);color:var(--acc2);transform:translateY(-1px);box-shadow:0 3px 12px rgba(160,16,48,.28)}
.btn:active{transform:translateY(0)}
.btn.active{background:var(--acc);border-color:var(--acc);color:#fff;box-shadow:0 0 12px rgba(212,59,85,.45)}
.btn.primary{background:var(--acc);border-color:var(--acc);color:#fff}
.btn.primary:hover{background:var(--acc2);color:#fff}
.sec h2{border-left:3px solid var(--acc);padding-left:7px}
.tag{color:var(--acc2);border-color:#3a3a40}
#panel{scrollbar-width:thin;scrollbar-color:var(--acc) transparent}
#drop.dragover{outline-color:var(--acc)}
#readmeOverlay{position:fixed;inset:0;background:rgba(0,0,0,.65);z-index:50;display:none;align-items:center;justify-content:center;padding:30px}
#readmeOverlay.show{display:flex}
#readmeWin{background:var(--panel);border:1px solid var(--line);border-top:3px solid var(--acc);border-radius:10px;
max-width:760px;width:100%;max-height:85vh;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,.6)}
#readmeHead{display:flex;align-items:center;padding:10px 16px;border-bottom:1px solid var(--line)}
#readmeHead b{font-size:13px;letter-spacing:.05em}
#readmeClose{margin-left:auto;cursor:pointer;color:var(--sub);font-size:18px;padding:0 6px;transition:color .15s}
#readmeClose:hover{color:var(--acc2)}
#readmeBody{overflow-y:auto;padding:18px 22px;font-size:13px;line-height:1.9;scrollbar-width:thin;scrollbar-color:var(--acc) transparent}
#readmeBody h1{font-size:18px;margin:10px 0;color:#fff;border-bottom:1px solid var(--line);padding-bottom:6px}
#readmeBody h2{font-size:15px;margin:16px 0 6px;color:var(--acc2)}
#readmeBody h3{font-size:13px;margin:12px 0 4px}
#readmeBody p{margin:4px 0}
#readmeBody ul{margin:6px 0 6px 20px}
#readmeBody code{background:#222226;border-radius:4px;padding:1px 5px;font-size:12px;color:var(--acc2)}
#readmeBody pre{background:#141416;border:1px solid var(--line);border-radius:6px;padding:10px;margin:8px 0;overflow-x:auto}
#readmeBody pre code{background:none;color:var(--txt);padding:0}
#readmeBody table{border-collapse:collapse;margin:10px 0;width:100%;font-size:12px}
#readmeBody th{background:#1c1c1f;color:var(--acc2);text-align:left}
#readmeBody th,#readmeBody td{border:1px solid var(--line);padding:5px 9px}
#readmeBody blockquote{border-left:3px solid var(--acc);padding:4px 12px;margin:8px 0;color:var(--sub);background:rgba(160,16,48,.06)}
#readmeBody a{color:var(--acc2)}
.sec>h2{cursor:pointer;user-select:none}
.sec>h2::after{content:'▾';float:right;color:var(--sub);font-size:10px;transition:transform .15s}
.sec.collapsed>h2::after{content:'▸'}
.sec.collapsed>*:not(h2){display:none}
#layers .ord{cursor:pointer;color:var(--sub);font-size:10px;padding:0 2px}
#layers .ord:hover{color:var(--acc2)}
</style>
</head>
<body>
<header><h1>Anime2.5DRig</h1><span>PSDをドロップ → 自動リグ → その場で動く 2.5Dアバター</span><button class="btn" id="btnReadme" style="margin-left:auto">README</button></header>
<div id="main">
<div id="stage" class="checker">
<canvas id="cv"></canvas>
<div id="fps"></div>
<div id="rigInfo"></div>
<div id="drop">
<h2>パーツ分けPSDをここにドロップ</h2>
<p>レイヤー命名規約: face / eyewhite / irides / eyelash / eye_close / eyebrow /<br>
mouth_open / mouth_close / nose / ears / neck / topwear / front hair / back hair など<br>
髪を複数レイヤーに分ける場合は front hair_1, front hair_2 …(詳細はREADME)</p>
<p>一枚絵しか無い場合 → <a href="https://huggingface.co/spaces/24yearsold/see-through-demo" target="_blank" rel="noopener" style="color:var(--acc2)">see-through(HuggingFace Space・無料)</a>で自動分割したPSDをそのままドロップ。<br>口レイヤーのリネームと、足りない閉じ目・閉じ口差分の自動生成はこちらで行います。</p>
<div class="btns" style="justify-content:center">
<button class="btn primary" id="btnFile">PSDファイルを選ぶ</button>
<button class="btn" id="btnSample">sample.psd を読み込む</button>
<button class="btn" id="btnReadme2">READMEを読む</button>
</div>
<p id="dropStatus"></p>
<input type="file" id="fileInput" accept=".psd" style="display:none">
</div>
</div>
<div id="panel">
<div class="sec"><h2>自動</h2>
<div class="toggles">
<div class="tg on" id="tgIdle">アイドル</div>
<div class="tg on" id="tgBlink">自動まばたき</div>
<div class="tg on" id="tgRand">ランダム動作</div>
<div class="tg on" id="tgTalk">ランダム口パク</div>
<div class="tg" id="tgCam">カメラ追従</div>
<div class="tg" id="tgMouse">マウス追従</div>
<div class="tg" id="tgMic">マイク口パク</div>
<div class="tg on" id="tgPhys">髪の物理</div>
</div>
</div>
<div class="sec"><h2>表情プリセット</h2>
<div class="btns">
<button class="btn" data-preset="neutral">通常</button>
<button class="btn" data-preset="smile">笑顔</button>
<button class="btn" data-preset="usume">薄目</button>
<button class="btn" data-preset="surprise">驚き</button>
<button class="btn" data-preset="jito">ジト目</button>
<button class="btn" data-preset="winkL">ウインク左</button>
<button class="btn" data-preset="winkR">ウインク右</button>
</div>
</div>
<div class="sec"><h2>顔の向き</h2>
<div class="row"><label>角度 X</label><input type="range" id="pAngleX" min="-1" max="1" step="0.01" value="0"><span class="val"></span></div>
<div class="row"><label>角度 Y</label><input type="range" id="pAngleY" min="-1" max="1" step="0.01" value="0"><span class="val"></span></div>
<div class="row"><label>角度 Z</label><input type="range" id="pAngleZ" min="-1" max="1" step="0.01" value="0"><span class="val"></span></div>
</div>
<div class="sec"><h2></h2>
<div class="row"><label>左目の開き</label><input type="range" id="pEyeL" min="0" max="1" step="0.01" value="1"><span class="val"></span></div>
<div class="row"><label>右目の開き</label><input type="range" id="pEyeR" min="0" max="1" step="0.01" value="1"><span class="val"></span></div>
<div class="row"><label>視線 X</label><input type="range" id="pEyeX" min="-1" max="1" step="0.01" value="0"><span class="val"></span></div>
<div class="row"><label>視線 Y</label><input type="range" id="pEyeY" min="-1" max="1" step="0.01" value="0"><span class="val"></span></div>
<div class="row"><label>瞳スケール</label><input type="range" id="pIrisScale" min="0.5" max="1.3" step="0.01" value="1"><span class="val"></span></div>
<div class="row"><label>閉じ目スケール左</label><input type="range" id="pEyeScaleL" min="0.5" max="1.5" step="0.01" value="1"><span class="val"></span></div>
<div class="row"><label>閉じ目スケール右</label><input type="range" id="pEyeScaleR" min="0.5" max="1.5" step="0.01" value="1"><span class="val"></span></div>
<div class="row"><label>閉じやすさ</label><input type="range" id="pEyeEase" min="0" max="1" step="0.01" value="0.3"><span class="val"></span></div>
<div class="row"><label>差分 上下</label><input type="range" id="pEyeCY" min="-1" max="1" step="0.01" value="0"><span class="val"></span></div>
<div class="row"><label>差分 角度</label><input type="range" id="pEyeCAng" min="-1" max="1" step="0.01" value="0"><span class="val"></span></div>
</div>
<div class="sec"><h2></h2>
<div class="row"><label>眉(上下)</label><input type="range" id="pBrow" min="-1" max="1" step="0.01" value="0"><span class="val"></span></div>
<div class="row"><label>眉角度 対称</label><input type="range" id="pBrowAngSym" min="-1" max="1" step="0.01" value="0"><span class="val"></span></div>
<div class="row"><label>眉角度 左</label><input type="range" id="pBrowAngL" min="-1" max="1" step="0.01" value="0"><span class="val"></span></div>
<div class="row"><label>眉角度 右</label><input type="range" id="pBrowAngR" min="-1" max="1" step="0.01" value="0"><span class="val"></span></div>
</div>
<div class="sec"><h2></h2>
<div class="row"><label>開き</label><input type="range" id="pMouthOpen" min="0" max="1" step="0.01" value="0"><span class="val"></span></div>
<div class="row"><label>形(笑い)</label><input type="range" id="pMouthForm" min="-1" max="1" step="0.01" value="0"><span class="val"></span></div>
<div class="row"><label>閉じ口 上下</label><input type="range" id="pMouthCY" min="-1" max="1" step="0.01" value="0"><span class="val"></span></div>
<div class="row"><label>閉じやすさ</label><input type="range" id="pMouthEase" min="0" max="1" step="0.01" value="0.45"><span class="val"></span></div>
<div class="row"><label>閉じ口 角度</label><input type="range" id="pMouthCAng" min="-1" max="1" step="0.01" value="0"><span class="val"></span></div>
<div class="row"><label>口スケール</label><input type="range" id="pMouthScale" min="0.5" max="1.5" step="0.01" value="1"><span class="val"></span></div>
</div>
<div class="sec"><h2>前髪</h2>
<div class="row"><label>揺れ</label><input type="range" id="pFhAmp" min="0" max="3" step="0.01" value="2"><span class="val"></span></div>
<div class="row"><label>柔らかさ</label><input type="range" id="pFhSoft" min="0" max="2" step="0.01" value="0.4"><span class="val"></span></div>
<div class="row"><label>ブロック左</label><input type="range" id="pBangL" min="-1" max="1" step="0.01" value="0"><span class="val"></span></div>
<div class="row"><label>ブロック中央</label><input type="range" id="pBangC" min="-1" max="1" step="0.01" value="0"><span class="val"></span></div>
<div class="row"><label>ブロック右</label><input type="range" id="pBangR" min="-1" max="1" step="0.01" value="0"><span class="val"></span></div>
</div>
<div class="sec"><h2>体・物理</h2>
<div class="row"><label>体の傾き</label><input type="range" id="pBody" min="-1" max="1" step="0.01" value="0"><span class="val"></span></div>
<div class="row"><label>腕の高さ</label><input type="range" id="pArmY" min="-1" max="1" step="0.01" value="0"><span class="val"></span></div>
<div class="row"><label>腕位置補正</label><input type="range" id="pArmPos" min="-1" max="1" step="0.01" value="0"><span class="val"></span></div>
<div class="row"><label>胸の揺れ</label><input type="range" id="pBust" min="0" max="4" step="0.01" value="2.5"><span class="val"></span></div>
<div class="row"><label>胸位置 上下</label><input type="range" id="pBustY" min="-3" max="3" step="0.01" value="1"><span class="val"></span></div>
<div class="row"><label>後髪の揺れ</label><input type="range" id="pPhysAmp" min="0" max="3" step="0.01" value="2"><span class="val"></span></div>
<div class="row"><label>後髪柔らかさ</label><input type="range" id="pSoft" min="0" max="3" step="0.01" value="2"><span class="val"></span></div>
</div>
<div class="sec"><h2>ユーティリティ</h2>
<div class="btns">
<button class="btn" id="btnReset">数値リセット</button>
<button class="btn" id="btnSavePsd">軽量PSDを保存</button>
</div>
<div id="utilStatus" style="font-size:11px;color:var(--sub);margin-top:6px"></div>
</div>
<div class="sec"><h2>背景</h2>
<div class="btns">
<button class="btn" data-bg="checker">透過</button>
<button class="btn" data-bg="green">GB</button>
<button class="btn" data-bg="dark">ダーク</button>
</div>
</div>
<div class="sec"><h2>レイヤー(上=奥 / ▲▼で描画順変更)</h2><div id="layers"></div></div>
</div>
</div>
<div id="readmeOverlay"><div id="readmeWin">
<div id="readmeHead"><b>README</b><span id="readmeClose"></span></div>
<div id="readmeBody"></div>
</div></div>
<script src="lib/ag-psd.min.js"></script>
<script src="lib/rigger.js"></script>
<script src="lib/genericparts.js"></script>
<script>
(function(){
"use strict";
const cv = document.getElementById('cv');
const gl = cv.getContext('webgl', {alpha:true, stencil:true, antialias:true, premultipliedAlpha:true});
if(!gl){ document.getElementById('stage').textContent='WebGL未対応'; return; }
// ---------- GL setup ----------
function sh(type, src){ const s=gl.createShader(type); gl.shaderSource(s,src); gl.compileShader(s);
if(!gl.getShaderParameter(s,gl.COMPILE_STATUS)) throw gl.getShaderInfoLog(s); return s; }
const prog = gl.createProgram();
gl.attachShader(prog, sh(gl.VERTEX_SHADER,
'attribute vec2 aPos; attribute vec2 aUV; uniform vec2 uRes; varying vec2 vUV;'+
'void main(){ vUV=aUV; vec2 c = aPos/uRes*2.0-1.0; gl_Position=vec4(c.x,-c.y,0.0,1.0); }'));
gl.attachShader(prog, sh(gl.FRAGMENT_SHADER,
'precision mediump float; varying vec2 vUV; uniform sampler2D uTex; uniform float uCut; uniform float uAlpha;'+
'void main(){ vec4 c=texture2D(uTex,vUV); if(c.a<uCut) discard; gl_FragColor=c*uAlpha; }'));
gl.linkProgram(prog); gl.useProgram(prog);
const locPos=gl.getAttribLocation(prog,'aPos'), locUV=gl.getAttribLocation(prog,'aUV');
const locRes=gl.getUniformLocation(prog,'uRes'), locCut=gl.getUniformLocation(prog,'uCut'), locAl=gl.getUniformLocation(prog,'uAlpha');
gl.enableVertexAttribArray(locPos); gl.enableVertexAttribArray(locUV);
gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);
// ---------- model state (mutable, rebuilt per PSD) ----------
let layers=[], A=null, CW=768, CH=768, FS=1, NP=null, BP=null, FC=null, CHEST=null;
const bounce={x:0,v:0,dy:0};
function mkTex(imgData){
const t=gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D,t);
gl.texImage2D(gl.TEXTURE_2D,0,gl.RGBA,gl.RGBA,gl.UNSIGNED_BYTE,imgData);
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MIN_FILTER,gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MAG_FILTER,gl.LINEAR);
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);
return t;
}
function applyRig(rig){
for(const L of layers){ gl.deleteTexture(L.tex); gl.deleteBuffer(L.vboPos); gl.deleteBuffer(L.vboUV); gl.deleteBuffer(L.ibo); }
layers=[];
CW=rig.canvas.w; CH=rig.canvas.h; A=rig.anchors; FS=A.faceScale;
NP=A.neckPivot; BP=A.bodyPivot; FC={x:A.face.cx,y:A.face.cy};
CHEST={cx:NP.cx, cy:A.neckBottom+(A.face.y1-A.face.y0)*0.60,
rx:(A.face.x1-A.face.x0)*0.60, ry:(A.face.y1-A.face.y0)*0.45};
for(const Lr of rig.layers){
const L=Object.assign({},Lr);
const cell=(L.phys?30:42)*Math.max(0.6,CW/768);
const nx=Math.max(2,Math.round(L.w/cell)), ny=Math.max(2,Math.round(L.h/cell));
const nv=(nx+1)*(ny+1);
const base=new Float32Array(nv*2), uv=new Float32Array(nv*2);
let k=0;
for(let j=0;j<=ny;j++) for(let i=0;i<=nx;i++){
base[k]=L.x+L.w*i/nx; base[k+1]=L.y+L.h*j/ny; uv[k]=i/nx; uv[k+1]=j/ny; k+=2;
}
const idx=[];
for(let j=0;j<ny;j++) for(let i=0;i<nx;i++){
const a=j*(nx+1)+i, b=a+1, c=a+nx+1, d=c+1; idx.push(a,b,c,b,d,c);
}
L.base=base; L.cur=new Float32Array(base); L.nIdx=idx.length;
L.bn=Rigger.baseName(L.name.replace(/_(l|r)$/,''));
if(L.strands&&L.strands.length){
const S=L.strands, nS=S.length;
let spacing=120;
if(nS>1){ const ds=[]; for(let s=1;s<nS;s++)ds.push(S[s].x-S[s-1].x); ds.sort((a,b)=>a-b); spacing=ds[ds.length>>1]; }
const sig=spacing*0.6;
L.sw=new Float32Array(nv*nS); L.su=new Float32Array(nv);
L.spr=S.map((s,i)=>({stiff:{x:0,v:0,dx:0}, soft:{x:0,v:0,dx:0}, phase:i*1.37+L.z}));
for(let v=0;v<nv;v++){
const x=base[v*2], y=base[v*2+1];
let tot=0;
for(let s=0;s<nS;s++){ const w=Math.exp(-Math.pow((x-S[s].x)/sig,2)); L.sw[v*nS+s]=w; tot+=w; }
let rY=0,tY=0;
if(tot>1e-6){ for(let s=0;s<nS;s++){ L.sw[v*nS+s]/=tot; rY+=L.sw[v*nS+s]*S[s].rootY; tY+=L.sw[v*nS+s]*S[s].tipY; } }
else { L.sw[v*nS+0]=1; rY=S[0].rootY; tY=S[0].tipY; }
L.su[v]=Math.min(1,Math.max(0,(y-rY)/Math.max(1,tY-rY)));
}
if(L.bn==='front hair'){
const fw=A.face.x1-A.face.x0, fcx=A.face.cx;
const f=36, b1=fcx-fw*0.22, b2=fcx+fw*0.22;
L.bw=new Float32Array(nv*3);
for(let v=0;v<nv;v++){ const x=base[v*2];
const s1=smooth((x-b1)/f+0.5), s2=smooth((x-b2)/f+0.5);
L.bw[v*3]=1-s1; L.bw[v*3+1]=s1*(1-s2); L.bw[v*3+2]=s2; }
}
}
L.vboPos=gl.createBuffer(); L.vboUV=gl.createBuffer(); L.ibo=gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER,L.vboUV); gl.bufferData(gl.ARRAY_BUFFER,uv,gl.STATIC_DRAW);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER,L.ibo); gl.bufferData(gl.ELEMENT_ARRAY_BUFFER,new Uint16Array(idx),gl.STATIC_DRAW);
const idata=(typeof ImageData!=='undefined')?new ImageData(new Uint8ClampedArray(L.img.data),L.img.width,L.img.height):L.img;
L.tex=mkTex(idata); delete L.img;
layers.push(L);
}
cv.width=CW; cv.height=CH; fit();
renderLayerList();
const nStr=layers.reduce((s,L)=>s+(L.strands?L.strands.length:0),0);
document.getElementById('rigInfo').innerHTML =
layers.length+'パーツ / 髪'+nStr+'房を自動リグ'+
(rig.warnings.length?('<br>⚠ '+rig.warnings.join('<br>⚠ ')):'');
document.getElementById('drop').classList.add('hidden');
}
// ---------- PSD loading ----------
const dropEl=document.getElementById('drop'), dropStatus=document.getElementById('dropStatus');
async function loadPsdBuffer(buf){
try{
if(!window.agPsd) throw new Error('lib/ag-psd.min.js が未読込です。ZIPを展開してから開く / lib フォルダを index.html と同じ階層に配置してください');
if(!window.Rigger) throw new Error('lib/rigger.js が未読込です。lib フォルダの配置を確認してください');
dropStatus.textContent='解析中…';
try{ await customGenericReady; }catch(err){}
const psd=window.agPsd.readPsd(new Uint8Array(buf),{useImageData:true,skipThumbnail:true});
const pre=window.Rigger.cleanPsdLayers(psd); // 下処理: 全レイヤーのノイズ除去+トリム
lastPsd=psd; lastPre=pre;
const rig=window.Rigger.buildRig(psd, genericOpts());
applyRig(rig);
if(pre.noisy>0) document.getElementById('rigInfo').innerHTML +=
'<br>下処理: 低アルファノイズを '+pre.noisy+'/'+pre.layers+' レイヤーから除去(「軽量PSDを保存」で書き出せます)';
if(rig.synth&&(rig.synth.eye||rig.synth.mouth)) document.getElementById('rigInfo').innerHTML +=
'<br>差分ソース: '+(customGeneric?'ルートの eye_close.psd / mouth_close.psd':'内蔵の汎用差分');
dropStatus.textContent='';
}catch(err){
dropStatus.textContent='エラー: '+err.message;
dropEl.classList.remove('hidden');
}
}
let customGeneric=null;
async function loadCustomGeneric(){
if(typeof fetch==='undefined'||!window.agPsd||!window.Rigger) return;
async function grab(url){
try{
const r=await fetch(url); if(!r.ok) return null;
const psd=window.agPsd.readPsd(new Uint8Array(await r.arrayBuffer()),{useImageData:true,skipThumbnail:true});
return window.Rigger.flattenPsdToImg(psd);
}catch(err){ return null; }
}
const [eye,mouth]=await Promise.all([grab('eye_close.psd'),grab('mouth_close.psd')]);
const g={};
if(eye){ const s=window.Rigger.splitImgLR(eye); if(s){ g.eyeL=s.l; g.eyeR=s.r; } }
if(mouth) g.mouth=mouth;
if(g.eyeL||g.mouth) customGeneric=g;
}
const customGenericReady=loadCustomGeneric();
function genericOpts(){
const GP=window.GenericParts;
const base=GP?{eyeL:GP.get('eyeL'),eyeR:GP.get('eyeR'),mouth:GP.get('mouth')}:{};
const g=Object.assign({},base,customGeneric||{});
return (g.eyeL||g.mouth)?{generic:g}:{};
}
window.__loadPsdData=function(psd){ lastPre=window.Rigger.cleanPsdLayers(psd); lastPsd=psd; applyRig(window.Rigger.buildRig(psd, genericOpts())); }; // test hook
document.getElementById('btnFile').addEventListener('click',()=>document.getElementById('fileInput').click());
document.getElementById('fileInput').addEventListener('change',async ev=>{
const f=ev.target.files[0]; if(f) loadPsdBuffer(await f.arrayBuffer());
});
document.getElementById('btnSample').addEventListener('click',async ()=>{
try{
dropStatus.textContent='sample.psd を取得中…';
const r=await fetch('sample.psd');
if(!r.ok) throw new Error('sample.psd が見つかりません(HTTP '+r.status+')');
loadPsdBuffer(await r.arrayBuffer());
}catch(err){ dropStatus.textContent='エラー: '+err.message; }
});
(function(){ const miss=[];
if(!window.agPsd)miss.push('ag-psd.min.js'); if(!window.Rigger)miss.push('rigger.js'); if(!window.GenericParts)miss.push('genericparts.js');
if(miss.length) dropStatus.textContent='⚠ lib/'+miss.join(', lib/')+' が読み込めていません(ZIP展開・libフォルダの配置を確認)';
})();
['dragover','dragenter'].forEach(ev=>window.addEventListener(ev,e=>{ e.preventDefault(); dropEl.classList.add('dragover'); }));
['dragleave','drop'].forEach(ev=>window.addEventListener(ev,e=>{ e.preventDefault(); dropEl.classList.remove('dragover'); }));
window.addEventListener('drop',async e=>{
const f=e.dataTransfer&&e.dataTransfer.files&&e.dataTransfer.files[0];
if(f&&/\.psd$/i.test(f.name)){ dropEl.classList.remove('hidden'); loadPsdBuffer(await f.arrayBuffer()); }
});
// ---------- params ----------
const P = { angleX:0, angleY:0, angleZ:0, eyeOpenL:1, eyeOpenR:1, eyeX:0, eyeY:0, brow:0,
mouthOpen:0, mouthForm:0, mouthCY:0, body:0, physAmp:2, soft:2,
browAngL:0, browAngR:0, browAngSym:0, bangL:0, bangC:0, bangR:0,
armY:0, armPos:0, bust:2.5, bustY:1, irisScale:1, mouthEase:0.45, eyeEase:0.3,
fhAmp:2, fhSoft:0.4, eyeCY:0, eyeCAng:0, mouthCAng:0, eyeScaleL:1, eyeScaleR:1, mouthScale:1 };
const DEFAULTS = Object.assign({}, P);
let lastPsd=null, lastPre=null;
const T = Object.assign({}, P);
const cur = Object.assign({}, P);
const auto = { idle:true, blink:true, rand:true, talk:true, mouse:false, mic:false, phys:true, cam:false };
const sliders = { pAngleX:'angleX',pAngleY:'angleY',pAngleZ:'angleZ',pEyeL:'eyeOpenL',pEyeR:'eyeOpenR',
pEyeX:'eyeX',pEyeY:'eyeY',pBrow:'brow',pMouthOpen:'mouthOpen',pMouthForm:'mouthForm',
pBody:'body',pPhysAmp:'physAmp',pSoft:'soft',pMouthCY:'mouthCY',
pBrowAngL:'browAngL',pBrowAngR:'browAngR',pBrowAngSym:'browAngSym',
pBangL:'bangL',pBangC:'bangC',pBangR:'bangR',pArmY:'armY',pArmPos:'armPos',
pBust:'bust',pBustY:'bustY',pIrisScale:'irisScale',pMouthEase:'mouthEase',pEyeEase:'eyeEase',
pFhAmp:'fhAmp',pFhSoft:'fhSoft',pEyeCY:'eyeCY',pEyeCAng:'eyeCAng',pMouthCAng:'mouthCAng',
pEyeScaleL:'eyeScaleL',pEyeScaleR:'eyeScaleR',pMouthScale:'mouthScale' };
for(const id in sliders){
const el=document.getElementById(id), key=sliders[id], v=el.parentNode.querySelector('.val');
const upd=()=>{ T[key]=parseFloat(el.value); v.textContent=el.value; }; el.addEventListener('input',upd); upd();
}
function setSlider(key,val){ for(const id in sliders) if(sliders[id]===key){
const el=document.getElementById(id); el.value=val; el.dispatchEvent(new Event('input')); } }
[['tgIdle','idle'],['tgBlink','blink'],['tgRand','rand'],['tgTalk','talk'],['tgCam','cam'],['tgMouse','mouse'],['tgMic','mic'],['tgPhys','phys']].forEach(([id,key])=>{
const el=document.getElementById(id);
el.addEventListener('click',()=>{ auto[key]=!auto[key]; el.classList.toggle('on',auto[key]);
if(key==='mic'&&auto.mic) initMic();
if(key==='cam'){ if(auto.cam) initCam(); else el.textContent='カメラ追従'; } });
});
const presets={
neutral:{eyeOpenL:1,eyeOpenR:1,brow:0,mouthOpen:0,mouthForm:0,irisScale:1},
smile:{eyeOpenL:0,eyeOpenR:0,brow:0.45,mouthOpen:0,mouthForm:0.9,irisScale:1},
usume:{eyeOpenL:0.5,eyeOpenR:0.5,brow:0.35,mouthOpen:1,mouthForm:0.8,irisScale:1},
surprise:{eyeOpenL:1,eyeOpenR:1,brow:1,mouthOpen:0.75,mouthForm:-0.1,irisScale:0.7},
jito:{eyeOpenL:0.4,eyeOpenR:0.4,brow:-0.6,mouthOpen:0,mouthForm:-0.4,irisScale:1},
winkL:{eyeOpenL:0,eyeOpenR:1,brow:0.2,mouthOpen:0.4,mouthForm:0.7,irisScale:1},
winkR:{eyeOpenL:1,eyeOpenR:0,brow:0.2,mouthOpen:0.4,mouthForm:0.7,irisScale:1} };
let activePreset=null;
function clearPresetHighlight(){ document.querySelectorAll('[data-preset]').forEach(x=>x.classList.remove('active')); }
document.querySelectorAll('[data-preset]').forEach(b=>b.addEventListener('click',()=>{
const name=b.dataset.preset;
clearPresetHighlight();
if(activePreset===name){ activePreset=null;
const p=presets.neutral; for(const k in p) setSlider(k,p[k]);
} else {
activePreset=name; b.classList.add('active');
const p=presets[name]; for(const k in p) setSlider(k,p[k]);
}
}));
document.getElementById('btnReset').addEventListener('click',()=>{
activePreset=null; clearPresetHighlight();
for(const id in sliders) setSlider(sliders[id], DEFAULTS[sliders[id]]);
document.getElementById('utilStatus').textContent='全パラメータを初期値に戻しました';
});
document.getElementById('btnSavePsd').addEventListener('click',()=>{
const st=document.getElementById('utilStatus');
if(!lastPsd){ st.textContent='先にPSDを読み込んでください'; return; }
try{
st.textContent='書き出し中…';
const out=window.agPsd.writePsd(lastPsd,{generateThumbnail:false});
const blob=new Blob([out],{type:'application/octet-stream'});
const a=document.createElement('a');
a.href=URL.createObjectURL(blob); a.download='model_clean.psd'; a.click();
setTimeout(()=>URL.revokeObjectURL(a.href),5000);
st.textContent='軽量PSDを保存しました('+(blob.size/1e6).toFixed(1)+'MB / ノイズ除去+トリム済み)';
}catch(err){ st.textContent='書き出しエラー: '+err.message; }
});
document.querySelectorAll('[data-bg]').forEach(b=>b.addEventListener('click',()=>{
const s=document.getElementById('stage'); s.className='';
if(b.dataset.bg==='checker')s.classList.add('checker');
if(b.dataset.bg==='green')s.classList.add('green');
if(b.dataset.bg==='dark')s.style.background='#14151c'; else s.style.background='';
}));
// ---------- README viewer ----------
function mdToHtml(md){
const esc=s=>s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
const inline=s=>esc(s)
.replace(/\*\*(.+?)\*\*/g,'<b>$1</b>')
.replace(/`([^`]+)`/g,'<code>$1</code>')
.replace(/\[([^\]]+)\]\(([^)]+)\)/g,'<a href="$2" target="_blank" rel="noopener">$1</a>');
const lines=md.split(/\r?\n/);
let html='', inCode=false, inList=false, inTable=false;
for(const l of lines){
if(l.startsWith('```')){ html+=inCode?'</code></pre>':'<pre><code>'; inCode=!inCode; continue; }
if(inCode){ html+=esc(l)+'\n'; continue; }
if(/^\|/.test(l.trim())){
if(/^\|[\s\-|:]+\|?$/.test(l.trim())) continue;
const cells=l.trim().split('|').slice(1,-1).map(c=>inline(c.trim()));
if(!inTable){ html+='<table><tr>'+cells.map(c=>'<th>'+c+'</th>').join('')+'</tr>'; inTable=true; }
else html+='<tr>'+cells.map(c=>'<td>'+c+'</td>').join('')+'</tr>';
continue;
} else if(inTable){ html+='</table>'; inTable=false; }
if(/^\s*[-*] /.test(l)){ if(!inList){html+='<ul>';inList=true;}
html+='<li>'+inline(l.replace(/^\s*[-*] /,''))+'</li>'; continue; }
else if(inList){ html+='</ul>'; inList=false; }
const h=l.match(/^(#{1,4}) (.*)/);
if(h){ html+='<h'+h[1].length+'>'+inline(h[2])+'</h'+h[1].length+'>'; continue; }
if(/^> /.test(l)){ html+='<blockquote>'+inline(l.slice(2))+'</blockquote>'; continue; }
if(l.trim()!=='') html+='<p>'+inline(l)+'</p>';
}
if(inCode)html+='</code></pre>'; if(inList)html+='</ul>'; if(inTable)html+='</table>';
return html;
}
let readmeLoaded=false;
async function openReadme(){
const ov=document.getElementById('readmeOverlay');
ov.classList.add('show');
if(readmeLoaded) return;
const body=document.getElementById('readmeBody');
try{
const r=await fetch('README.md');
if(!r.ok) throw new Error('HTTP '+r.status);
body.innerHTML=mdToHtml(await r.text());
readmeLoaded=true;
}catch(err){
body.innerHTML='<p>README.md を取得できませんでした('+err.message+')。'+
'<a href="https://github.com/852wa/Anime2.5DRig#readme" target="_blank" rel="noopener">GitHubで読む</a></p>';
}
}
document.getElementById('btnReadme').addEventListener('click',openReadme);
document.getElementById('btnReadme2').addEventListener('click',openReadme);
document.getElementById('readmeClose').addEventListener('click',()=>document.getElementById('readmeOverlay').classList.remove('show'));
document.getElementById('readmeOverlay').addEventListener('click',e=>{ if(e.target.id==='readmeOverlay') e.target.classList.remove('show'); });
// ---------- mouse ----------
function renderLayerList(){
document.getElementById('layers').innerHTML = layers.map((L,i)=>
'<div style="display:flex;align-items:center;gap:3px">'+
'<span class="ord" data-i="'+i+'" data-d="-1">▲</span><span class="ord" data-i="'+i+'" data-d="1">▼</span>'+
'<b>'+L.name+'</b>'+
(L.synthetic?'<span class="tag">自動生成</span>':'')+
(L.strands?'<span class="tag">房×'+L.strands.length+'</span>':'')+
(L.side?'<span class="tag">'+L.side+'</span>':'')+
(L.fade?'<span class="tag">差分</span>':'')+'</div>').join('');
}
document.getElementById('layers').addEventListener('click',e=>{
const t=e.target; if(!t.classList||!t.classList.contains('ord'))return;
const i=+t.dataset.i, j=i+(+t.dataset.d);
if(j<0||j>=layers.length)return;
const tmp=layers[i]; layers[i]=layers[j]; layers[j]=tmp;
renderLayerList();
});
document.querySelectorAll('#panel .sec > h2').forEach(h=>h.addEventListener('click',()=>h.parentNode.classList.toggle('collapsed')));
let mouse={x:0,y:0,in:false};
cv.addEventListener('mousemove',e=>{ const r=cv.getBoundingClientRect();
mouse.x=(e.clientX-r.left)/r.width*2-1; mouse.y=(e.clientY-r.top)/r.height*2-1; mouse.in=true; });
cv.addEventListener('mouseleave',()=>mouse.in=false);
// ---------- mic ----------
let micLevel=0, analyser=null, micBuf=null;
function initMic(){ if(analyser) return;
navigator.mediaDevices.getUserMedia({audio:true}).then(st=>{
const ac=new (window.AudioContext||window.webkitAudioContext)();
const src=ac.createMediaStreamSource(st); analyser=ac.createAnalyser();
analyser.fftSize=512; micBuf=new Uint8Array(analyser.frequencyBinCount); src.connect(analyser);
}).catch(()=>{ auto.mic=false; document.getElementById('tgMic').classList.remove('on'); });
}
// ---------- webcam face tracking (MediaPipe FaceMesh, lazy CDN) ----------
const cam={live:false,ax:0,ay:0,az:0,eL:1,eR:1,mo:0,ex:0,ey:0};
let camPhysScale=1; // カメラ追従中は揺れ・柔らかさを0.5倍
let camVideo=null, camFM=null, camBusy=false;
function loadScript(u){return new Promise((res,rej)=>{const s=document.createElement('script');s.src=u;s.onload=res;s.onerror=rej;document.head.appendChild(s);});}
let camLoopOn=false;
async function camLoop(){
if(!auto.cam){ cam.live=false; camLoopOn=false; return; } // OFFでループ終了
if(!camBusy&&camVideo&&camVideo.readyState>=2){
camBusy=true; try{ await camFM.send({image:camVideo}); }catch(err){} camBusy=false;
}
setTimeout(camLoop,33);
}
async function initCam(){
const chip=document.getElementById('tgCam');
if(camFM){ // 再ON: ループを再起動
try{ if(camVideo&&camVideo.paused) await camVideo.play(); }catch(err){}
chip.textContent='カメラ追従中';
if(!camLoopOn){ camLoopOn=true; camLoop(); }
return;
}
let st=null;
try{
chip.textContent='カメラ準備中…';
if(!window.FaceMesh){
try{ await loadScript('https://cdn.jsdelivr.net/npm/@mediapipe/face_mesh/face_mesh.js'); }
catch(e){ throw Object.assign(new Error('cdn'),{name:'CdnError'}); }
}
try{
st=await navigator.mediaDevices.getUserMedia({video:{width:{ideal:640},height:{ideal:480}}});
}catch(e1){
if(e1.name==='OverconstrainedError'||e1.name==='ConstraintNotSatisfiedError'){
st=await navigator.mediaDevices.getUserMedia({video:true}); // 制約を緩めて再試行
} else throw e1;
}
camVideo=document.createElement('video'); camVideo.srcObject=st; camVideo.muted=true; camVideo.playsInline=true;
await camVideo.play();
camFM=new window.FaceMesh({locateFile:f=>'https://cdn.jsdelivr.net/npm/@mediapipe/face_mesh/'+f});
camFM.setOptions({maxNumFaces:1,refineLandmarks:true,minDetectionConfidence:0.5,minTrackingConfidence:0.5});
camFM.onResults(onFace);
chip.textContent='カメラ追従中';
if(!camLoopOn){ camLoopOn=true; camLoop(); }
}catch(err){
console.error('camera init failed:', err);
if(st){ try{ st.getTracks().forEach(t=>t.stop()); }catch(e){} } // 後始末して次回リトライ可能に
camVideo=null; camFM=null;
const msg=
err.name==='NotAllowedError'||err.name==='PermissionDeniedError' ? 'カメラ許可が必要' :
err.name==='NotReadableError'||err.name==='TrackStartError' ? 'カメラ使用中(他アプリ)' :
err.name==='NotFoundError'||err.name==='DevicesNotFoundError' ? 'カメラ未検出' :
err.name==='CdnError' ? 'モデル読込失敗(通信)' :
!window.isSecureContext ? 'httpsが必要' : 'カメラ不可';
chip.textContent=msg;
auto.cam=false; chip.classList.remove('on');
}
}
function onFace(res){
const lm=res.multiFaceLandmarks&&res.multiFaceLandmarks[0];
if(!lm){ cam.live=false; return; }
cam.live=true;
const Pt=i=>lm[i];
const d=(a,b)=>Math.hypot(Pt(a).x-Pt(b).x, Pt(a).y-Pt(b).y);
const mix=(o,v)=>o+(v-o)*0.5;
const chL=Pt(454), chR=Pt(234), nose=Pt(1), fh=Pt(10), chin=Pt(152);
const fw=Math.hypot(chL.x-chR.x, chL.y-chR.y)||1e-4;
const yaw=((nose.x-(chL.x+chR.x)/2)/fw)*3.2;
const pitch=(((nose.y-(fh.y+chin.y)/2)/Math.abs(chin.y-fh.y||1e-4))-0.10)*3.0;
const roll=Math.atan2(Pt(263).y-Pt(33).y, Pt(263).x-Pt(33).x);
cam.ax=mix(cam.ax,-yaw); cam.ay=mix(cam.ay,-pitch); cam.az=mix(cam.az,-roll*1.4);
const eo=(t,b,c1,c2)=>{ const r=d(t,b)/(d(c1,c2)||1e-4); return Math.min(1,Math.max(0,(r-0.09)/0.17)); };
cam.eL=mix(cam.eL, eo(159,145,33,133));
cam.eR=mix(cam.eR, eo(386,374,362,263));
const mr=d(13,14)/(d(61,291)||1e-4);
cam.mo=mix(cam.mo, Math.min(1,Math.max(0,(mr-0.03)/0.32)));
if(lm.length>=478){
const gz=(ir,c1,c2)=>{ const mx=(Pt(c1).x+Pt(c2).x)/2, w=Math.abs(Pt(c1).x-Pt(c2).x)||1e-4;
return (Pt(ir).x-mx)/w; };
const gx=(gz(468,33,133)+gz(473,362,263))/2;
const gy=((Pt(468).y+Pt(473).y)/2-(Pt(159).y+Pt(145).y+Pt(386).y+Pt(374).y)/4)/fw;
cam.ex=mix(cam.ex,-gx*5); cam.ey=mix(cam.ey,-gy*10);
}
}
// ---------- animation state ----------
let blinkT=-1, nextBlink=(typeof performance!=='undefined'?performance.now():0)+1800;
let rnd={ax:0,ay:0,az:0,bd:0,ex:0,ey:0}, nextRnd=0;
let talkOn=false, talkV=0, talkTgt=0, nextTalkState=0, nextSyl=0;
function clamp(v,a,b){return v<a?a:v>b?b:v}
function smooth(t){t=clamp(t,0,1);return t*t*(3-2*t)}
function fadeAlpha(L,e){
if(!L.fade) return 1;
if(L.fade==='eyeOpen'){ const v=L.side==='L'?e.eyeOpenL:e.eyeOpenR; return smooth((v-(0.10+e.eyeEase*0.45))/0.15); }
if(L.fade==='eyeClose'){ const v=L.side==='L'?e.eyeOpenL:e.eyeOpenR; return 1-smooth((v-(0.10+e.eyeEase*0.45))/0.15); }
if(L.fade==='mouthOpen') return smooth((e.mouthOpen-(0.05+e.mouthEase*0.35))/0.12);
if(L.fade==='mouthClose') return 1-smooth((e.mouthOpen-(0.05+e.mouthEase*0.35))/0.12);
return 1;
}
function deform(L, e){
const b=L.base, o=L.cur, n=b.length;
const isHead=L.group==='head';
const az=e.angleZ*0.07, cz=Math.cos(az), sz=Math.sin(az);
const ab=e.body*0.028, cb=Math.cos(ab), sb=Math.sin(ab);
const nm=L.name, bn=L.bn;
const eyeSide=L.side, EA=eyeSide==='L'?A.eyeL:(eyeSide==='R'?A.eyeR:null);
const vOpen=eyeSide==='L'?e.eyeOpenL:e.eyeOpenR;
const mo=e.mouthOpen;
const mHalfW=(A.mouth.x1-A.mouth.x0)/2;
const nS=L.strands?L.strands.length:0;
const bcx=L.x+L.w/2, bcy=L.y+L.h/2;
const isFH=(bn==='front hair');
for(let k=0;k<n;k+=2){
let x=b[k], y=b[k+1];
const vi=k>>1;
// --- closed-eye / mouth scale ---
if(EA && bn==='eye_close'){
const sE=eyeSide==='L'?e.eyeScaleL:e.eyeScaleR;
if(sE!==1){ const cxE=(EA.x0+EA.x1)/2, cyE=(EA.y0+EA.y1)/2;
x=cxE+(x-cxE)*sE; y=cyE+(y-cyE)*sE; }
}
if(bn==='mouth_open'||bn==='mouth_close'){
const sM=e.mouthScale;
if(sM!==1){ x=A.mouth.cx+(x-A.mouth.cx)*sM; y=A.mouth.cy+(y-A.mouth.cy)*sM; }
}
// --- local features ---
if(L.fade==='eyeOpen'&&EA){
if(bn==='irides'){
const isc=e.irisScale;
x=EA.icx+(x-EA.icx)*isc; y=EA.icy+(y-EA.icy)*isc;
x+=e.eyeX*11*FS; y+=e.eyeY*6*FS;
const tl=smooth((0.32-vOpen)/0.32); // iris stays round until nearly closed
y = EA.closeY + (y-EA.closeY)*(1-0.80*tl);
} else {
y = EA.closeY + (y-EA.closeY)*(1-0.85*(1-vOpen)); // lid compression
}
}
if(L.fade==='eyeClose'&&EA){
y -= vOpen*3;
y += e.eyeCY*14*FS;
const thE=e.eyeCAng*0.3*(eyeSide==='L'?1:-1);
if(thE){ const ct=Math.cos(thE), st=Math.sin(thE), rx=x-bcx, ry=y-bcy;
x=bcx+rx*ct-ry*st; y=bcy+rx*st+ry*ct; }
}
if(bn==='eyebrow'){
y += (-e.brow*9 + (1-vOpen)*3.5)*FS;
const th=(eyeSide==='L'?(e.browAngL+e.browAngSym):(e.browAngR-e.browAngSym))*0.30;
if(th){ const ct=Math.cos(th), st=Math.sin(th), rx=x-bcx, ry=y-bcy;
x=bcx+rx*ct-ry*st; y=bcy+rx*st+ry*ct; }
}
if(L.fade==='mouthOpen'){
y = A.mouth.y0 + (y-A.mouth.y0)*(0.5+0.5*mo);
const q=Math.pow(Math.abs(x-A.mouth.cx)/(mHalfW+4),1.5);
y -= e.mouthForm*6*FS*(q-0.35);
}
if(L.fade==='mouthClose'){
y += e.mouthCY*14*FS; // 形(笑い)は閉じ口には適用しない
const thM=e.mouthCAng*0.35;
if(thM){ const ct=Math.cos(thM), st=Math.sin(thM), rx=x-A.mouth.cx, ry=y-A.mouth.cy;
x=A.mouth.cx+rx*ct-ry*st; y=A.mouth.cy+rx*st+ry*ct; }
}
if(bn==='face' && y>A.mouth.cy){
y += mo*6*FS*smooth((y-A.mouth.cy)/(A.face.y1-A.mouth.cy));
}
// --- head transform ---
let hw = isHead?1:(L.group==='body'?0.16:0); // body subtly follows head XYZ
if(bn==='neck') hw = 0.55*smooth((A.neckBottom-y)/Math.max(1,A.neckBottom-A.neckTop));
if(hw>0){
let rx=x-NP.cx, ry=y-NP.cy;
const rx2=rx*cz-ry*sz, ry2=rx*sz+ry*cz;
x+=(rx2-rx)*hw; y+=(ry2-ry)*hw;
const dd=L.depth;
x += hw*FS*( e.angleX*(14+40*(dd-1)) + e.angleX*(NP.cy-y)*0.028 );
y += hw*FS*( -e.angleY*(9+30*(dd-1)) - e.angleY*(dd-1)*(y-FC.y)*0.05 );
}
// --- breathing ---
y -= (L.group==='body'?e.breath*2.0:e.breathHead*1.6)*FS;
if(bn==='topwear'&&y<CHEST.cy) y -= e.breath*2.2*FS*smooth((CHEST.cy-y)/(CHEST.ry*2)); // shoulders rise
if(bn==='topwear') x = NP.cx + (x-NP.cx)*(1+e.breath*0.003);
// --- bust jiggle ---
if(bn==='topwear'){
const gx=(x-CHEST.cx)/CHEST.rx, gy=(y-(CHEST.cy+e.bustY*70*FS))/CHEST.ry;
y += bounce.dy*e.bust*Math.exp(-gx*gx-gy*gy);
}
// --- arms ---
if(bn==='handwear'){
const w=smooth((y-L.y)/L.h*1.15);
y -= e.armY*30*FS*w;
y += e.armPos*40*FS;
x += e.armY*6*FS*w*(x<NP.cx?1:-1);
}
// --- bang blocks ---
if(L.bw&&L.su){ const m=Math.pow(L.su[vi],1.4)*22*FS;
x += (e.bangL*L.bw[vi*3]+e.bangC*L.bw[vi*3+1]+e.bangR*L.bw[vi*3+2])*m;
}
// --- hair strand physics (stiff top, fluffy bottom; front hair has own params) ---
if(nS && auto.phys){
const u=isFH?Math.min(1,L.su[vi]*1.6):L.su[vi];
const amp=Math.pow(u,isFH?1.8:2.1)*(isFH?e.fhAmp:e.physAmp);
const softMix=Math.pow(u,1.2)*(isFH?e.fhSoft:e.soft);
let dx=0;
for(let s=0;s<nS;s++){
const w=L.sw[vi*nS+s]; if(w<0.001)continue;
const sp=L.spr[s];
dx += w*( sp.stiff.dx*(1-softMix) + sp.soft.dx*softMix );
}
x += dx*amp; y += Math.abs(dx)*amp*0.12;
}
o[k]=x; o[k+1]=y;
}
// --- body rotation (around bottom center) ---
if(Math.abs(ab)>1e-4){
for(let k=0;k<n;k+=2){
const rx=o[k]-BP.cx, ry=o[k+1]-BP.cy;
o[k]=BP.cx+rx*cb-ry*sb; o[k+1]=BP.cy+rx*sb+ry*cb;
}
}
}
// ---------- main loop ----------
let last=(typeof performance!=='undefined'?performance.now():0), fpsN=0, fpsT=last;
function fit(){
const st=document.getElementById('stage'), s=Math.min(st.clientWidth/CW, st.clientHeight/CH);
cv.style.width=(CW*s)+'px'; cv.style.height=(CH*s)+'px';
}
window.addEventListener('resize',fit);
function tick(now){
requestAnimationFrame(tick);
if(!layers.length||!A) return;
const dt=Math.min(0.05,(now-last)/1000); last=now;
const t=now/1000;
let tgt=Object.assign({},T);
const camLive=auto.cam&&cam.live;
if(camLive){
tgt.angleX=clamp(cam.ax,-1,1); tgt.angleY=clamp(cam.ay,-1,1); tgt.angleZ=clamp(cam.az,-1,1);
tgt.eyeOpenL=Math.min(tgt.eyeOpenL,cam.eL); tgt.eyeOpenR=Math.min(tgt.eyeOpenR,cam.eR);
tgt.eyeX=clamp(cam.ex,-1,1); tgt.eyeY=clamp(cam.ey,-1,1);
tgt.mouthOpen=Math.max(tgt.mouthOpen,cam.mo);
tgt.body=clamp(tgt.body+cam.ax*0.25,-1,1);
}
if(!camLive&&auto.mouse&&mouse.in){ tgt.angleX=clamp(mouse.x*0.9,-1,1); tgt.angleY=clamp(-mouse.y*0.7,-1,1);
tgt.eyeX=clamp(mouse.x*1.2,-1,1); tgt.eyeY=clamp(-mouse.y*0.8,-1,1); }
if(auto.idle&&!camLive){
tgt.angleX+=0.13*Math.sin(t*0.42)+0.05*Math.sin(t*1.13);
tgt.angleY+=0.08*Math.sin(t*0.31+1.7);
tgt.angleZ+=0.07*Math.sin(t*0.23+0.5);
tgt.body +=0.10*Math.sin(t*0.19+2.1);
}
if(auto.rand&&!camLive){
if(now>nextRnd){ nextRnd=now+1400+Math.random()*2600;
rnd.ax=(Math.random()*2-1)*0.55; rnd.ay=(Math.random()*2-1)*0.40;
rnd.az=(Math.random()*2-1)*0.35; rnd.bd=(Math.random()*2-1)*0.30;
rnd.ex=(Math.random()*2-1)*0.60; rnd.ey=(Math.random()*2-1)*0.35; }
tgt.angleX=clamp(tgt.angleX+rnd.ax,-1,1); tgt.angleY=clamp(tgt.angleY+rnd.ay,-1,1);
tgt.angleZ=clamp(tgt.angleZ+rnd.az,-1,1); tgt.body=clamp(tgt.body+rnd.bd,-1,1);
tgt.eyeX=clamp(tgt.eyeX+rnd.ex,-1,1); tgt.eyeY=clamp(tgt.eyeY+rnd.ey,-1,1);
}
if(auto.talk){
if(now>nextTalkState){ talkOn=!talkOn; nextTalkState=now+(talkOn?1200+Math.random()*2200:600+Math.random()*1800); }
if(talkOn&&now>nextSyl){ nextSyl=now+70+Math.random()*110; talkTgt=Math.random()<0.25?0.04:0.25+Math.random()*0.75; }
if(!talkOn)talkTgt=0;
talkV+=(talkTgt-talkV)*Math.min(1,dt*22);
tgt.mouthOpen=Math.max(tgt.mouthOpen,talkV);
}
if(auto.blink){
if(blinkT<0 && now>nextBlink){ blinkT=0; nextBlink=now+1600+Math.random()*3800; if(Math.random()<0.18)nextBlink=now+280; }
if(blinkT>=0){ blinkT+=dt;
const d=blinkT; let v;
if(d<0.08)v=1-d/0.08; else if(d<0.42)v=0; else if(d<0.58)v=(d-0.42)/0.16; else{v=1;blinkT=-1;}
tgt.eyeOpenL=Math.min(tgt.eyeOpenL,v); tgt.eyeOpenR=Math.min(tgt.eyeOpenR,v);
}
}
if(auto.mic&&analyser){ analyser.getByteFrequencyData(micBuf);
let s=0; for(let i=2;i<40;i++)s+=micBuf[i]; s/=38*255;
micLevel+=(clamp(s*3.2,0,1)-micLevel)*0.45;
tgt.mouthOpen=Math.max(tgt.mouthOpen,micLevel);
}
for(const k in cur) cur[k]+=(tgt[k]-cur[k])*Math.min(1,dt*14);
const e=Object.assign({},cur);
camPhysScale+=((camLive?0.5:1)-camPhysScale)*Math.min(1,dt*4);
e.physAmp*=camPhysScale; e.soft*=camPhysScale; e.fhAmp*=camPhysScale; e.fhSoft*=camPhysScale;
e.breath=0.5+0.5*Math.sin(t*2*Math.PI/3.4);
e.breathHead=0.5+0.5*Math.sin(t*2*Math.PI/3.4-0.6); // head follows chest with a lag
// strand springs
const headDX=(e.angleX*14+e.angleZ*0.07*(NP.cy-FC.y))*FS;
for(const L of layers){
if(!L.spr) continue;
for(const sp of L.spr){
const wind=auto.idle?(1.8*Math.sin(t*0.8+sp.phase)+1.0*Math.sin(t*1.9+sp.phase*2.3)):0;
const txv=headDX+wind*FS;
let kk=70,cc=9;
let axv=-kk*(sp.stiff.x-txv)-cc*sp.stiff.v; sp.stiff.v+=axv*dt; sp.stiff.x+=sp.stiff.v*dt;
sp.stiff.dx=-(sp.stiff.x-txv)*2.2;
kk=16;cc=1.3;
axv=-kk*(sp.soft.x-txv)-cc*sp.soft.v; sp.soft.v+=axv*dt; sp.soft.x+=sp.soft.v*dt;
sp.soft.dx=-(sp.soft.x-txv)*3.0;
}
}
// bust bounce
{ const bustTgt=(e.breath*3.0 - e.angleY*6.0 + e.body*4.0)*FS;
const kk=140, cc=4.2;
const aa=-kk*(bounce.x-bustTgt)-cc*bounce.v; bounce.v+=aa*dt; bounce.x+=bounce.v*dt;
bounce.dy=-(bounce.x-bustTgt)*3.0; }
gl.viewport(0,0,CW,CH);
gl.clearColor(0,0,0,0); gl.clearStencil(0);
gl.clear(gl.COLOR_BUFFER_BIT|gl.STENCIL_BUFFER_BIT);
gl.uniform2f(locRes,CW,CH);
for(const L of layers){
const fa=fadeAlpha(L,e);
if(fa<0.004 && !(L.fade==='eyeOpen'&&L.name.indexOf('eyewhite')===0)) continue;
deform(L,e);
gl.uniform1f(locAl,fa);
gl.bindBuffer(gl.ARRAY_BUFFER,L.vboPos);
gl.bufferData(gl.ARRAY_BUFFER,L.cur,gl.DYNAMIC_DRAW);
gl.vertexAttribPointer(locPos,2,gl.FLOAT,false,0,0);
gl.bindBuffer(gl.ARRAY_BUFFER,L.vboUV);
gl.vertexAttribPointer(locUV,2,gl.FLOAT,false,0,0);
gl.bindTexture(gl.TEXTURE_2D,L.tex);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER,L.ibo);
if(L.name.indexOf('eyewhite')===0){
gl.enable(gl.STENCIL_TEST);
gl.stencilFunc(gl.ALWAYS,1,0xff); gl.stencilOp(gl.KEEP,gl.KEEP,gl.REPLACE);
gl.uniform1f(locCut,0.25);
gl.drawElements(gl.TRIANGLES,L.nIdx,gl.UNSIGNED_SHORT,0);
gl.disable(gl.STENCIL_TEST); gl.uniform1f(locCut,0.0);
} else if(L.name.indexOf('irides')===0){
gl.enable(gl.STENCIL_TEST);
gl.stencilFunc(gl.EQUAL,1,0xff); gl.stencilOp(gl.KEEP,gl.KEEP,gl.KEEP);
gl.drawElements(gl.TRIANGLES,L.nIdx,gl.UNSIGNED_SHORT,0);
gl.disable(gl.STENCIL_TEST);
} else {
gl.drawElements(gl.TRIANGLES,L.nIdx,gl.UNSIGNED_SHORT,0);
}
}
fpsN++; if(now-fpsT>500){ document.getElementById('fps').textContent=Math.round(fpsN*1000/(now-fpsT))+' fps';
fpsN=0; fpsT=now; }
}
requestAnimationFrame(tick);
})();
</script>
</body>
</html>

Xet Storage Details

Size:
50.1 kB
·
Xet hash:
bab6cac1836bc00e36cf8f1a2d2cb2ce4894d56d93c9bbf9fd014ca9c672e653

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.