Buckets:
| <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(){ | |
| ; | |
| 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,'&').replace(/</g,'<').replace(/>/g,'>'); | |
| 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.