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)} | |
| </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> | |
| <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="toggles"> | |
| <div class="tg" id="tgMesh">疑似3Dメッシュ</div> | |
| </div> | |
| <div style="font-size:10px;color:var(--sub);margin-top:6px">変形後のメッシュ(ワイヤーフレーム)を上に重ねて表示</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="btns"> | |
| <button class="btn" id="btnPoseSave">現在を保存</button> | |
| <button class="btn" id="btnPosePlay">▶ 連続再生</button> | |
| <button class="btn" id="btnPoseClear">全消去</button> | |
| </div> | |
| <div class="toggles" id="poseList" style="margin-top:8px"></div> | |
| <div style="font-size:10px;color:var(--sub);margin-top:6px">クリックで適用 / Shift+クリックで削除。全スライダー値を保存します</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>疑似3D</h2> | |
| <div class="row"><label>3Dシェル</label><input type="range" id="pShell" min="0" max="1" step="0.01" value="0.5"><span class="val"></span></div> | |
| <div class="row"><label>鼻の高さ</label><input type="range" id="pNose" min="0" max="0.6" step="0.01" value="0.22"><span class="val"></span></div> | |
| <div class="row"><label>顎の高さ</label><input type="range" id="pChin" min="0" max="0.6" step="0.01" value="0.15"><span class="val"></span></div> | |
| <div class="row"><label>Eライン</label><input type="range" id="pEline" min="-0.3" max="0.3" step="0.01" value="0"><span class="val"></span></div> | |
| <div class="row"><label>前髪の浮き</label><input type="range" id="pHairGap" min="0" max="0.5" step="0.01" value="0.18"><span class="val"></span></div> | |
| <div class="row"><label>前髪のふくらみ</label><input type="range" id="pHairBulge" min="0" max="1.5" step="0.01" value="1"><span class="val"></span></div> | |
| <div class="row"><label>後髪の奥行き</label><input type="range" id="pBackDepth" min="0" max="0.8" step="0.01" value="0.35"><span class="val"></span></div> | |
| <div class="row"><label>体シェル</label><input type="range" id="pBodyShell" min="0" max="1" step="0.01" value="0.5"><span class="val"></span></div> | |
| <div class="row"><label>体の回り込み</label><input type="range" id="pBodyYaw" min="0" max="1" step="0.01" value="1"><span class="val"></span></div> | |
| <div class="row"><label>張り出し(手前)</label><input type="range" id="pChestNear" min="0" max="2.5" step="0.01" value="1"><span class="val"></span></div> | |
| <div class="row"><label>引っ張り(奥)</label><input type="range" id="pChestFar" min="0" max="2.5" step="0.01" value="1"><span class="val"></span></div> | |
| <div class="row"><label>左乳首 位置</label><input type="range" id="pNipL" min="0" max="1.5" step="0.01" value="0.83"><span class="val"></span></div> | |
| <div class="row"><label>右乳首 位置</label><input type="range" id="pNipR" min="0" max="1.5" step="0.01" value="0.83"><span class="val"></span></div> | |
| <div class="row"><label>胸中央</label><input type="range" id="pChestCenter" min="0" max="1.5" step="0.01" value="1.5"><span class="val"></span></div> | |
| </div> | |
| <div class="sec"><h2>横顔カーブ</h2> | |
| <div class="toggles"> | |
| <div class="tg" id="tgProfCurve">カーブ使用</div> | |
| <button class="btn" id="btnProfReset">リセット</button> | |
| </div> | |
| <canvas id="profCv" width="268" height="150" style="display:block;margin-top:8px;border:1px solid var(--line);border-radius:4px;background:#0a0a0c;cursor:grab"></canvas> | |
| <div style="font-size:10px;color:var(--sub);margin-top:6px">点をドラッグして横顔の輪郭を調整(左=奥へ引っ込む / 右=前へ突き出す)</div> | |
| </div> | |
| <div class="sec"><h2>胸カーブ</h2> | |
| <div class="toggles"> | |
| <div class="tg" id="tgChestCurve">カーブ使用</div> | |
| <button class="btn" id="btnChestReset">リセット</button> | |
| </div> | |
| <canvas id="chestCv" width="268" height="150" style="display:block;margin-top:8px;border:1px solid var(--line);border-radius:4px;background:#0a0a0c;cursor:grab"></canvas> | |
| <div style="font-size:10px;color:var(--sub);margin-top:6px">乳房の側面プロファイル(首元→胸先→腰)。右=手前に膨らむ / 左=引っ込む。両乳首位置に立体を作ります</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="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="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> | |
| <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="pFhCrownRound" min="0" max="2.5" step="0.01" value="0"><span class="val"></span></div> | |
| <div class="toggles"> | |
| <div class="tg" id="tgPart">生え際を固定</div> | |
| <div class="tg" id="tgPartViz">密着域を表示</div> | |
| </div> | |
| <div class="row"><label>密着域 中心X</label><input type="range" id="pPinX" min="-1" max="1" step="0.01" value="0"><span class="val"></span></div> | |
| <div class="row"><label>密着域 中心Y</label><input type="range" id="pPinY" min="-1" max="1" step="0.01" value="-0.45"><span class="val"></span></div> | |
| <div class="row"><label>密着域 幅</label><input type="range" id="pPinW" min="0.10" max="1.50" step="0.01" value="1.10"><span class="val"></span></div> | |
| <div class="row"><label>密着域 高さ</label><input type="range" id="pPinH" min="0.05" max="1.00" step="0.01" value="0.32"><span class="val"></span></div> | |
| <div class="row"><label>境界ぼかし</label><input type="range" id="pPinFeather" min="0" max="0.30" step="0.01" value="0.06"><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="pBustDamp" min="0" max="1" step="0.01" value="0.5"><span class="val"></span></div> | |
| <div class="row"><label>ランダムの速さ</label><input type="range" id="pRndSpeed" min="0.25" max="1.5" step="0.01" value="0.6"><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="btnExportTune">調整値を書出</button> | |
| <button class="btn" id="btnImportTune">調整値を読込</button> | |
| <button class="btn" id="btnRecord">録画開始</button> | |
| <button class="btn" id="btnSavePsd">軽量PSDを保存</button> | |
| </div> | |
| <input type="file" id="tuneInput" accept=".json,application/json" style="display:none"> | |
| <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> | |
| (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); | |
| // ---------- debug wireframe program (position-only, solid colour) ---------- | |
| const progLine = gl.createProgram(); | |
| gl.attachShader(progLine, sh(gl.VERTEX_SHADER, | |
| 'attribute vec2 aPos; uniform vec2 uRes;'+ | |
| 'void main(){ vec2 c = aPos/uRes*2.0-1.0; gl_Position=vec4(c.x,-c.y,0.0,1.0); gl_PointSize=7.0; }')); | |
| gl.attachShader(progLine, sh(gl.FRAGMENT_SHADER, | |
| 'precision mediump float; uniform vec4 uColor; void main(){ gl_FragColor=uColor; }')); | |
| gl.linkProgram(progLine); | |
| const locLPos=gl.getAttribLocation(progLine,'aPos'); | |
| const locLRes=gl.getUniformLocation(progLine,'uRes'), locLColor=gl.getUniformLocation(progLine,'uColor'); | |
| const markVbo=gl.createBuffer(); // dynamic buffer for curve-point markers | |
| // ---------- model state (mutable, rebuilt per PSD) ---------- | |
| let layers=[], A=null, CW=768, CH=768, FS=1, NP=null, BP=null, FC=null, CHEST=null, HEAD=null, HAIR=null, BODY=null; | |
| let profY0=0, profY1=1; // face-profile vertical span (extends a bit below the face bbox to reach the chin tip) | |
| const bounce={x:0,v:0,dy:0}; | |
| let yawBState=0; // low-passed body yaw: the torso follows the head with a lag (no twitch) | |
| 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); if(L.lbo) gl.deleteBuffer(L.lbo); } | |
| 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}; | |
| // 3D body shell: vertical elliptic cylinder around the neck axis (no y dependence — | |
| // the waist is just the lower part of the cylinder for now) | |
| BODY={cx:NP.cx, rx:(A.face.x1-A.face.x0)*0.95, rz:(A.face.x1-A.face.x0)*0.55, | |
| y0:A.neckBottom, y1:Math.min(CH, A.neckBottom+(A.face.y1-A.face.y0)*2.5)}; | |
| // 2.5D shell: head approximated as an ellipsoid fit to the face bbox. | |
| // rz controls how far the "nose plane" sits in front of the neck axis. | |
| HEAD={cx:A.face.cx, cy:A.face.y0+(A.face.y1-A.face.y0)*0.45, | |
| rx:(A.face.x1-A.face.x0)*0.62, ry:(A.face.y1-A.face.y0)*0.72, | |
| rz:(A.face.x1-A.face.x0)*0.45}; | |
| // nose tip: gaussian bump on the shell, centred on the nose layer if present, | |
| // otherwise estimated between the eyes and the mouth | |
| { | |
| const noseL=rig.layers.find(l=>Rigger.baseName(l.name)==='nose'); | |
| let ncx, ncy; | |
| if(noseL){ ncx=noseL.x+noseL.w/2; ncy=noseL.y+noseL.h*0.65; } | |
| else { | |
| const ey=(A.eyeL&&A.eyeR)?((A.eyeL.icy+A.eyeR.icy)/2):A.face.cy; | |
| ncx=A.face.cx; ncy=ey+(A.mouth.cy-ey)*0.55; | |
| } | |
| HEAD.nose={cx:ncx, cy:ncy, | |
| sx:(A.face.x1-A.face.x0)*0.11, sy:(A.face.y1-A.face.y0)*0.14}; | |
| // chin tip bump; together with the nose it defines the E-line ridge | |
| HEAD.chin={cx:A.face.cx, cy:A.face.y1-(A.face.y1-A.face.y0)*0.06, | |
| sx:(A.face.x1-A.face.x0)*0.18, sy:(A.face.y1-A.face.y0)*0.12}; | |
| // per-model profile-curve defaults: pin 鼻根 to eye level (nose root), | |
| // 鼻先 to the nose anchor, 上唇 to the top of the mouth | |
| // profile span runs from the top of the face to ~12% below the bbox, so the 顎 point | |
| // can be dragged down to the drawn chin tip (which sits just below the face-layer bottom) | |
| profY0=A.face.y0; profY1=A.face.y1+(A.face.y1-A.face.y0)*0.12; | |
| const v_=yy=>clamp((yy-profY0)/Math.max(1,profY1-profY0),0,1); | |
| const rootY=(A.eyeL&&A.eyeR)?((A.eyeL.icy+A.eyeR.icy)/2):(ncy-(A.face.y1-A.face.y0)*0.20); | |
| profAnchorDefaults=[ | |
| {v:0.06, z:0.10}, | |
| {v:v_(rootY), z:0.02}, | |
| {v:v_(ncy), z:0.30}, | |
| {v:v_(A.mouth.y0), z:0.06}, | |
| {v:v_(A.face.y1), z:0.14}]; | |
| for(let pi=1;pi<profAnchorDefaults.length;pi++) | |
| profAnchorDefaults[pi].v=Math.max(profAnchorDefaults[pi].v, profAnchorDefaults[pi-1].v+0.03); | |
| if(!profTouched){ // user hasn't customized the curve — adopt the model-fitted defaults | |
| profPoints=profAnchorDefaults.map(p=>Object.assign({},p)); | |
| rebuildProfLUT(); drawProfCv(); | |
| } | |
| } | |
| // per-model chest-curve defaults: 胸先 at the bust centre, 下乳 one radius below | |
| { | |
| const vB_=yy=>clamp((yy-BODY.y0)/Math.max(1,BODY.y1-BODY.y0),0,1); | |
| const bustV=vB_(CHEST.cy), underV=vB_(CHEST.cy+CHEST.ry); | |
| chestAnchorDefaults=[ | |
| {v:0.02, z:0.0}, | |
| {v:0.02+(bustV-0.02)*0.5, z:0.10}, | |
| {v:bustV, z:0.28}, | |
| {v:underV, z:0.06}, | |
| {v:0.95, z:0.02}]; | |
| for(let pi=1;pi<chestAnchorDefaults.length;pi++) | |
| chestAnchorDefaults[pi].v=Math.max(chestAnchorDefaults[pi].v, chestAnchorDefaults[pi-1].v+0.03); | |
| if(!chestTouched){ | |
| chestPoints=chestAnchorDefaults.map(p=>Object.assign({},p)); | |
| rebuildChestLUT(); drawChestCv(); | |
| } | |
| } | |
| // scalp shell: slightly larger + higher ellipsoid for the hair layers, so the | |
| // bangs float above the forehead and the back hair reads as a mass behind the head | |
| HAIR={cx:HEAD.cx, cy:HEAD.cy-HEAD.ry*0.06, | |
| rx:HEAD.rx*1.10, ry:HEAD.ry*1.10, rz:HEAD.rz*1.05}; | |
| 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); | |
| } | |
| // grid-edge indices for the debug wireframe overlay (horizontal + vertical lines) | |
| const lidx=[]; | |
| for(let j=0;j<=ny;j++) for(let i=0;i<nx;i++){ const a=j*(nx+1)+i; lidx.push(a,a+1); } | |
| for(let i=0;i<=nx;i++) for(let j=0;j<ny;j++){ const a=j*(nx+1)+i; lidx.push(a,a+nx+1); } | |
| L.base=base; L.cur=new Float32Array(base); L.nIdx=idx.length; L.nLine=lidx.length; L.nx=nx; L.ny=ny; | |
| 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(); L.lbo=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); | |
| gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER,L.lbo); gl.bufferData(gl.ELEMENT_ARRAY_BUFFER,new Uint16Array(lidx),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(); | |
| document.getElementById('layers').innerHTML = layers.map(L=> | |
| '<div><b>'+L.name+'</b> d='+L.depth.toFixed(2)+ | |
| (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(''); | |
| 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{ | |
| dropStatus.textContent='解析中…'; | |
| 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); | |
| applyRig(rig); | |
| if(pre.noisy>0) document.getElementById('rigInfo').innerHTML += | |
| '<br>下処理: 低アルファノイズを '+pre.noisy+'/'+pre.layers+' レイヤーから除去(「軽量PSDを保存」で書き出せます)'; | |
| dropStatus.textContent=''; | |
| }catch(err){ | |
| dropStatus.textContent='エラー: '+err.message; | |
| dropEl.classList.remove('hidden'); | |
| } | |
| } | |
| window.__loadPsdData=function(psd){ lastPre=window.Rigger.cleanPsdLayers(psd); lastPsd=psd; applyRig(window.Rigger.buildRig(psd)); }; // 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; } | |
| }); | |
| ['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, fhCrownRound:0, shell:0.5, noseAmp:0.22, chinAmp:0.15, elineAmp:0, | |
| hairGap:0.18, hairBulge:1, backDepth:0.35, bodyShell:0.5, | |
| pinX:0, pinY:-0.45, pinW:1.10, pinH:0.32, pinFeather:0.06, | |
| bodyYaw:1, chestNear:1, chestFar:1, nipXL:0.83, nipXR:0.83, chestCenter:1.5, | |
| bustDamp:0.5, rndSpeed:0.6 }; | |
| 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, mesh:false, part:false, partViz:false }; | |
| let pinPreviewUntil=0; // 矩形操作の直後だけ、密着域を短く表示して効果を確認しやすくする | |
| 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',pFhCrownRound:'fhCrownRound',pShell:'shell',pNose:'noseAmp',pChin:'chinAmp',pEline:'elineAmp', | |
| pHairGap:'hairGap',pHairBulge:'hairBulge',pBackDepth:'backDepth', | |
| pPinX:'pinX',pPinY:'pinY',pPinW:'pinW',pPinH:'pinH',pPinFeather:'pinFeather', | |
| pBodyShell:'bodyShell',pBodyYaw:'bodyYaw',pChestNear:'chestNear',pChestFar:'chestFar', | |
| pNipL:'nipXL',pNipR:'nipXR',pChestCenter:'chestCenter', | |
| pBustDamp:'bustDamp',pRndSpeed:'rndSpeed' }; | |
| 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; | |
| if(key.indexOf('pin')===0) pinPreviewUntil=performance.now()+900; }; | |
| 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')); } } | |
| // ---------- 横顔カーブ (side-profile curve editor) ---------- | |
| // 5 fixed control points, drawn in left-facing side view: widget x = z (depth, nose points right), | |
| // widget y = v (vertical position, 0=forehead..1=chin). deform() only does a LUT lookup per vertex. | |
| const PROF_LABELS=['額','鼻根','鼻先','上唇','顎']; | |
| const PROF_DEFAULTS=[ | |
| {v:0.06, z:0.10}, {v:0.42, z:0.02}, {v:0.62, z:0.30}, {v:0.78, z:0.06}, {v:0.97, z:0.14}]; | |
| let profPoints=PROF_DEFAULTS.map(p=>Object.assign({},p)); | |
| let profAnchorDefaults=null; // per-model defaults derived from rig anchors (set in applyRig) | |
| let profEnabled=false; | |
| const PROF_LUT_N=64; | |
| let profLUT=new Float32Array(PROF_LUT_N); | |
| function catmullRom(p0,p1,p2,p3,t){ | |
| const t2=t*t, t3=t2*t; | |
| return 0.5*((2*p1)+(-p0+p2)*t+(2*p0-5*p1+4*p2-p3)*t2+(-p0+3*p1-3*p2+p3)*t3); | |
| } | |
| function profEval(v){ | |
| const pts=profPoints; | |
| if(v<=pts[0].v) return pts[0].z; | |
| if(v>=pts[pts.length-1].v) return pts[pts.length-1].z; | |
| let i=0; while(i<pts.length-2 && v>pts[i+1].v) i++; | |
| const p1=pts[i], p2=pts[i+1]; | |
| const p0=pts[i-1]||p1, p3=pts[i+2]||p2; | |
| const span=(p2.v-p1.v)||1e-6, t=(v-p1.v)/span; | |
| return catmullRom(p0.z,p1.z,p2.z,p3.z,t); | |
| } | |
| function rebuildProfLUT(){ | |
| for(let i=0;i<PROF_LUT_N;i++) profLUT[i]=profEval(i/(PROF_LUT_N-1)); | |
| } | |
| function profLUTLookup(v){ | |
| v=v<0?0:(v>1?1:v); | |
| const f=v*(PROF_LUT_N-1), i0=Math.floor(f), i1=Math.min(PROF_LUT_N-1,i0+1), t=f-i0; | |
| return profLUT[i0]+(profLUT[i1]-profLUT[i0])*t; | |
| } | |
| function persistProfile(){ | |
| profTouched=true; | |
| try{ localStorage.setItem('a25d_profile', JSON.stringify({points:profPoints, enabled:profEnabled})); }catch(err){} | |
| } | |
| // one-way sync curve→sliders: derive the legacy gaussian slider values from the | |
| // curve points so the curve stays the single source of truth even with カーブ使用 OFF. | |
| // (moving the sliders never modifies the curve) | |
| function syncProfToSliders(){ | |
| const pts=profPoints; | |
| setSlider('noseAmp', clamp(pts[2].z, 0, 0.6)); | |
| setSlider('chinAmp', clamp(pts[4].z, 0, 0.6)); | |
| // E-line: deviation of 上唇 from the straight 鼻先→顎先 line at 上唇's v | |
| const dv=pts[4].v-pts[2].v; | |
| const t=Math.abs(dv)<1e-6?0.5:(pts[3].v-pts[2].v)/dv; | |
| const line=pts[2].z+(pts[4].z-pts[2].z)*t; | |
| setSlider('elineAmp', clamp(pts[3].z-line, -0.3, 0.3)); | |
| } | |
| const profWasSaved=(function loadProfile(){ | |
| try{ | |
| const saved=JSON.parse(localStorage.getItem('a25d_profile')||'null'); | |
| if(saved&&Array.isArray(saved.points)&&saved.points.length===PROF_DEFAULTS.length){ | |
| profPoints=saved.points.map(p=>({v:+p.v,z:+p.z})); | |
| profEnabled=!!saved.enabled; | |
| return true; | |
| } | |
| }catch(err){} | |
| return false; | |
| })(); | |
| let profTouched=profWasSaved; // false until the user edits/saves — anchor defaults may overwrite | |
| rebuildProfLUT(); | |
| if(profWasSaved) syncProfToSliders(); // pristine first visit keeps the P defaults | |
| const profCv=document.getElementById('profCv'), profCx=profCv.getContext('2d'); | |
| const tgProfCurve=document.getElementById('tgProfCurve'); | |
| tgProfCurve.classList.toggle('on',profEnabled); | |
| tgProfCurve.addEventListener('click',()=>{ | |
| profEnabled=!profEnabled; tgProfCurve.classList.toggle('on',profEnabled); persistProfile(); | |
| }); | |
| document.getElementById('btnProfReset').addEventListener('click',()=>{ | |
| profPoints=(profAnchorDefaults||PROF_DEFAULTS).map(p=>Object.assign({},p)); | |
| rebuildProfLUT(); drawProfCv(); syncProfToSliders(); persistProfile(); | |
| }); | |
| // widget mapping: x = z in [-0.4,0.8] -> [8,260]; y = v in [0,1] -> [8,142] | |
| const PROF_PAD=8, PROF_W=profCv.width-PROF_PAD*2, PROF_H=profCv.height-PROF_PAD*2; | |
| function profZtoX(z){ return PROF_PAD+((z+0.4)/1.2)*PROF_W; } | |
| function profXtoZ(x){ return ((x-PROF_PAD)/PROF_W)*1.2-0.4; } | |
| function profVtoY(v){ return PROF_PAD+v*PROF_H; } | |
| function profYtoV(y){ return (y-PROF_PAD)/PROF_H; } | |
| function drawProfCv(){ | |
| const w=profCv.width, h=profCv.height; | |
| profCx.clearRect(0,0,w,h); | |
| profCx.fillStyle='#0a0a0c'; profCx.fillRect(0,0,w,h); | |
| // z=0 reference line (bare ellipsoid surface) | |
| const x0=profZtoX(0); | |
| profCx.strokeStyle='rgba(255,255,255,.16)'; profCx.lineWidth=1; | |
| profCx.beginPath(); profCx.moveTo(x0,PROF_PAD); profCx.lineTo(x0,h-PROF_PAD); profCx.stroke(); | |
| // curve | |
| profCx.strokeStyle='#d43b55'; profCx.lineWidth=2; profCx.beginPath(); | |
| for(let i=0;i<PROF_LUT_N;i++){ | |
| const v=i/(PROF_LUT_N-1), z=profLUT[i]; | |
| const x=profZtoX(z), y=profVtoY(v); | |
| if(i===0) profCx.moveTo(x,y); else profCx.lineTo(x,y); | |
| } | |
| profCx.stroke(); | |
| // control points + labels | |
| profCx.font='9px sans-serif'; profCx.fillStyle='#8d93ad'; profCx.textBaseline='middle'; | |
| profPoints.forEach((p,i)=>{ | |
| const x=profZtoX(p.z), y=profVtoY(p.v); | |
| profCx.beginPath(); profCx.arc(x,y,4,0,Math.PI*2); | |
| profCx.fillStyle='#d43b55'; profCx.fill(); | |
| profCx.fillStyle='#8d93ad'; | |
| const label=PROF_LABELS[i]; | |
| if(x>w-30) profCx.fillText(label,x-8,y-8); else profCx.fillText(label,x+8,y-8); | |
| }); | |
| } | |
| drawProfCv(); | |
| let profDrag=-1; | |
| function profPickPoint(mx,my){ | |
| let best=-1, bd=14*14; | |
| profPoints.forEach((p,i)=>{ | |
| const dx=mx-profZtoX(p.z), dy=my-profVtoY(p.v), d2=dx*dx+dy*dy; | |
| if(d2<bd){ bd=d2; best=i; } | |
| }); | |
| return best; | |
| } | |
| function profCvPos(ev){ | |
| const r=profCv.getBoundingClientRect(); | |
| return {x:(ev.clientX-r.left)*(profCv.width/r.width), y:(ev.clientY-r.top)*(profCv.height/r.height)}; | |
| } | |
| profCv.addEventListener('mousedown',ev=>{ | |
| const pos=profCvPos(ev); | |
| profDrag=profPickPoint(pos.x,pos.y); | |
| if(profDrag>=0) profCv.style.cursor='grabbing'; | |
| }); | |
| window.addEventListener('mousemove',ev=>{ | |
| if(profDrag<0) return; | |
| const pos=profCvPos(ev); | |
| let z=profXtoZ(pos.x), v=profYtoV(pos.y); | |
| z=clamp(z,-0.4,0.8); | |
| const pts=profPoints, i=profDrag; | |
| const lo=i>0?pts[i-1].v:0, hi=i<pts.length-1?pts[i+1].v:1; | |
| const eps=0.01; | |
| v=clamp(v, i===0?0:lo+eps, i===pts.length-1?1:hi-eps); | |
| pts[i].z=z; pts[i].v=v; | |
| rebuildProfLUT(); drawProfCv(); syncProfToSliders(); | |
| }); | |
| window.addEventListener('mouseup',()=>{ | |
| if(profDrag>=0){ profDrag=-1; profCv.style.cursor='grab'; persistProfile(); } | |
| }); | |
| window.__setProfilePoints=function(pts){ profPoints=pts.map(p=>Object.assign({},p)); rebuildProfLUT(); drawProfCv(); syncProfToSliders(); }; // test hook | |
| window.__getProfilePoints=function(){ return profPoints.map(p=>({v:p.v,z:p.z})); }; // test hook | |
| // ---------- 胸カーブ (chest/torso side-profile curve editor) ---------- | |
| // Same left-facing side view as 横顔カーブ, spanning the torso (v=0 neckBottom .. v=1 below the bust). | |
| // In deform() the curve multiplicatively modulates the body cylinder's cross-section radius, so | |
| // bust rows swell and lead laterally under rotation (横に張り出し). Reuses the widget mapping + catmullRom. | |
| const CHEST_LABELS=['首元','胸上','胸先','下乳','腰']; | |
| const CHEST_DEFAULTS=[ | |
| {v:0.02, z:0.0}, {v:0.30, z:0.10}, {v:0.55, z:0.28}, {v:0.72, z:0.06}, {v:0.95, z:0.02}]; | |
| let chestPoints=CHEST_DEFAULTS.map(p=>Object.assign({},p)); | |
| let chestAnchorDefaults=null; | |
| let chestEnabled=true; // chest curve on by default so the breast deformation is live | |
| let chestLUT=new Float32Array(PROF_LUT_N); | |
| function chestEval(v){ | |
| const pts=chestPoints; | |
| if(v<=pts[0].v) return pts[0].z; | |
| if(v>=pts[pts.length-1].v) return pts[pts.length-1].z; | |
| let i=0; while(i<pts.length-2 && v>pts[i+1].v) i++; | |
| const p1=pts[i], p2=pts[i+1]; | |
| const p0=pts[i-1]||p1, p3=pts[i+2]||p2; | |
| const span=(p2.v-p1.v)||1e-6, t=(v-p1.v)/span; | |
| return catmullRom(p0.z,p1.z,p2.z,p3.z,t); | |
| } | |
| function rebuildChestLUT(){ | |
| for(let i=0;i<PROF_LUT_N;i++) chestLUT[i]=chestEval(i/(PROF_LUT_N-1)); | |
| } | |
| function chestLUTLookup(v){ | |
| v=v<0?0:(v>1?1:v); | |
| const f=v*(PROF_LUT_N-1), i0=Math.floor(f), i1=Math.min(PROF_LUT_N-1,i0+1), t=f-i0; | |
| return chestLUT[i0]+(chestLUT[i1]-chestLUT[i0])*t; | |
| } | |
| function persistChestProfile(){ | |
| chestTouched=true; | |
| try{ localStorage.setItem('a25d_chestprofile', JSON.stringify({points:chestPoints, enabled:chestEnabled})); }catch(err){} | |
| } | |
| const chestWasSaved=(function loadChestProfile(){ | |
| try{ | |
| const saved=JSON.parse(localStorage.getItem('a25d_chestprofile')||'null'); | |
| if(saved&&Array.isArray(saved.points)&&saved.points.length===CHEST_DEFAULTS.length){ | |
| chestPoints=saved.points.map(p=>({v:+p.v,z:+p.z})); | |
| chestEnabled=!!saved.enabled; | |
| return true; | |
| } | |
| }catch(err){} | |
| return false; | |
| })(); | |
| let chestTouched=chestWasSaved; | |
| rebuildChestLUT(); | |
| const chestCv=document.getElementById('chestCv'), chestCx=chestCv.getContext('2d'); | |
| const tgChestCurve=document.getElementById('tgChestCurve'); | |
| tgChestCurve.classList.toggle('on',chestEnabled); | |
| tgChestCurve.addEventListener('click',()=>{ | |
| chestEnabled=!chestEnabled; tgChestCurve.classList.toggle('on',chestEnabled); persistChestProfile(); | |
| }); | |
| document.getElementById('btnChestReset').addEventListener('click',()=>{ | |
| chestPoints=(chestAnchorDefaults||CHEST_DEFAULTS).map(p=>Object.assign({},p)); | |
| rebuildChestLUT(); drawChestCv(); persistChestProfile(); | |
| }); | |
| function drawChestCv(){ | |
| const w=chestCv.width, h=chestCv.height; | |
| chestCx.clearRect(0,0,w,h); | |
| chestCx.fillStyle='#0a0a0c'; chestCx.fillRect(0,0,w,h); | |
| const x0=profZtoX(0); | |
| chestCx.strokeStyle='rgba(255,255,255,.16)'; chestCx.lineWidth=1; | |
| chestCx.beginPath(); chestCx.moveTo(x0,PROF_PAD); chestCx.lineTo(x0,h-PROF_PAD); chestCx.stroke(); | |
| chestCx.strokeStyle='#d43b55'; chestCx.lineWidth=2; chestCx.beginPath(); | |
| for(let i=0;i<PROF_LUT_N;i++){ | |
| const v=i/(PROF_LUT_N-1), z=chestLUT[i]; | |
| const x=profZtoX(z), y=profVtoY(v); | |
| if(i===0) chestCx.moveTo(x,y); else chestCx.lineTo(x,y); | |
| } | |
| chestCx.stroke(); | |
| chestCx.font='9px sans-serif'; chestCx.textBaseline='middle'; | |
| chestPoints.forEach((p,i)=>{ | |
| const x=profZtoX(p.z), y=profVtoY(p.v); | |
| chestCx.beginPath(); chestCx.arc(x,y,4,0,Math.PI*2); | |
| chestCx.fillStyle='#d43b55'; chestCx.fill(); | |
| chestCx.fillStyle='#8d93ad'; | |
| const label=CHEST_LABELS[i]; | |
| if(x>w-30) chestCx.fillText(label,x-8,y-8); else chestCx.fillText(label,x+8,y-8); | |
| }); | |
| } | |
| drawChestCv(); | |
| let chestDrag=-1; | |
| function chestPickPoint(mx,my){ | |
| let best=-1, bd=14*14; | |
| chestPoints.forEach((p,i)=>{ | |
| const dx=mx-profZtoX(p.z), dy=my-profVtoY(p.v), d2=dx*dx+dy*dy; | |
| if(d2<bd){ bd=d2; best=i; } | |
| }); | |
| return best; | |
| } | |
| function chestCvPos(ev){ | |
| const r=chestCv.getBoundingClientRect(); | |
| return {x:(ev.clientX-r.left)*(chestCv.width/r.width), y:(ev.clientY-r.top)*(chestCv.height/r.height)}; | |
| } | |
| chestCv.addEventListener('mousedown',ev=>{ | |
| const pos=chestCvPos(ev); | |
| chestDrag=chestPickPoint(pos.x,pos.y); | |
| if(chestDrag>=0) chestCv.style.cursor='grabbing'; | |
| }); | |
| window.addEventListener('mousemove',ev=>{ | |
| if(chestDrag<0) return; | |
| const pos=chestCvPos(ev); | |
| let z=profXtoZ(pos.x), v=profYtoV(pos.y); | |
| z=clamp(z,-0.4,0.8); | |
| const pts=chestPoints, i=chestDrag; | |
| const lo=i>0?pts[i-1].v:0, hi=i<pts.length-1?pts[i+1].v:1; | |
| const eps=0.01; | |
| v=clamp(v, i===0?0:lo+eps, i===pts.length-1?1:hi-eps); | |
| pts[i].z=z; pts[i].v=v; | |
| rebuildChestLUT(); drawChestCv(); | |
| }); | |
| window.addEventListener('mouseup',()=>{ | |
| if(chestDrag>=0){ chestDrag=-1; chestCv.style.cursor='grab'; persistChestProfile(); } | |
| }); | |
| window.__setChestPoints=function(pts){ chestPoints=pts.map(p=>Object.assign({},p)); rebuildChestLUT(); drawChestCv(); }; // test hook | |
| window.__getChestPoints=function(){ return chestPoints.map(p=>({v:p.v,z:p.z})); }; // test hook | |
| window.__pinProbe=function(angleX,partOn,tx,ty,over){ // test hook: deform the front-hair vertex nearest (tx,ty), no rAF needed | |
| const L=layers.find(l=>l.bn==='front hair'); if(!L||!A) return null; | |
| const saved=auto.part; auto.part=partOn; | |
| const e=Object.assign({},cur,over||{}); | |
| e.angleX=angleX; e.angleY=0; e.angleZ=0; e.body=0; e.breath=0; e.breathHead=0; e.yawB=0; | |
| const pinCx=HEAD.cx+e.pinX*HEAD.rx, pinCy=HEAD.cy+e.pinY*HEAD.ry; | |
| const pinHw=e.pinW*HEAD.rx, pinHh=e.pinH*HEAD.ry; | |
| if(tx===undefined){ tx=pinCx; ty=pinCy; } | |
| let bi=0,bd=1e9; | |
| for(let k=0;k<L.base.length;k+=2){ const d=Math.hypot(L.base[k]-tx,L.base[k+1]-ty); if(d<bd){bd=d;bi=k;} } | |
| deform(L,e); | |
| const edge=frontHairPinEdge(L.base[bi],L.base[bi+1],pinCx,pinCy,pinHw,pinHh); | |
| const weight=partOn?frontHairPinWeight(L.base[bi],L.base[bi+1],pinCx,pinCy,pinHw,pinHh,e):0; | |
| const r={base:[L.base[bi],L.base[bi+1]],out:[L.cur[bi],L.cur[bi+1]],part:auto.part, | |
| pinRect:{x:pinCx,y:pinCy,halfW:pinHw,halfH:pinHh},pinEdge:edge,pinWeight:weight}; | |
| auto.part=saved; return r; | |
| }; | |
| [['tgIdle','idle'],['tgBlink','blink'],['tgRand','rand'],['tgTalk','talk'],['tgCam','cam'],['tgMouse','mouse'],['tgMic','mic'],['tgPhys','phys'],['tgMesh','mesh'],['tgPart','part'],['tgPartViz','partViz']].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==='part') localStorage.setItem('a25d_part',auto.part?'1':'0'); | |
| if(key==='cam'){ if(auto.cam) initCam(); else el.textContent='カメラ追従'; } }); | |
| }); | |
| // 生え際固定はモデル設定としてリロード後も維持する。 | |
| if(localStorage.getItem('a25d_part')==='1'){ auto.part=true; document.getElementById('tgPart').classList.add('on'); } | |
| 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]); | |
| } | |
| })); | |
| // ---------- pose presets (full slider snapshots, sequential eased playback) ---------- | |
| let poses=[]; | |
| try{ poses=JSON.parse(localStorage.getItem('a25d_poses')||'[]'); }catch(err){ poses=[]; } | |
| const posePlay={on:false, idx:-1, t0:0, from:null, to:null, phase:'hold', jit:0}; | |
| function persistPoses(){ try{ localStorage.setItem('a25d_poses',JSON.stringify(poses)); }catch(err){} } | |
| function renderPoses(){ | |
| const box=document.getElementById('poseList'); | |
| box.innerHTML=''; | |
| poses.forEach((p,i)=>{ | |
| const el=document.createElement('div'); el.className='tg'; el.textContent=p.name; | |
| el.title='クリックで適用 / Shift+クリックで削除'; | |
| el.addEventListener('click',ev=>{ | |
| if(ev.shiftKey){ poses.splice(i,1); persistPoses(); renderPoses(); return; } | |
| for(const k in p.params) setSlider(k,p.params[k]); | |
| }); | |
| box.appendChild(el); | |
| }); | |
| } | |
| renderPoses(); | |
| document.getElementById('btnPoseSave').addEventListener('click',()=>{ | |
| poses.push({name:'P'+(poses.length+1), params:Object.assign({},T)}); | |
| persistPoses(); renderPoses(); | |
| }); | |
| document.getElementById('btnPoseClear').addEventListener('click',()=>{ | |
| poses=[]; persistPoses(); renderPoses(); | |
| posePlay.on=false; document.getElementById('btnPosePlay').classList.remove('active'); | |
| }); | |
| document.getElementById('btnPosePlay').addEventListener('click',function(){ | |
| posePlay.on=!posePlay.on; this.classList.toggle('active',posePlay.on); | |
| if(posePlay.on){ posePlay.to=null; posePlay.idx=-1; } | |
| }); | |
| function nextPose(now){ | |
| posePlay.from=Object.assign({},cur); | |
| posePlay.idx=(posePlay.idx+1)%poses.length; | |
| posePlay.to=Object.assign({},poses[posePlay.idx].params); | |
| posePlay.phase='move'; posePlay.t0=now; posePlay.jit=Math.random()*1.4; | |
| } | |
| document.getElementById('btnReset').addEventListener('click',()=>{ | |
| activePreset=null; clearPresetHighlight(); | |
| for(const id in sliders) setSlider(sliders[id], DEFAULTS[sliders[id]]); | |
| document.getElementById('utilStatus').textContent='全パラメータを初期値に戻しました'; | |
| }); | |
| function currentSliderParams(){ | |
| const params={}; | |
| for(const id in sliders){ | |
| const key=sliders[id], el=document.getElementById(id); | |
| params[key]=parseFloat(el.value); | |
| } | |
| return params; | |
| } | |
| function setModelToggle(id,key,on){ | |
| const el=document.getElementById(id); | |
| auto[key]=!!on; el.classList.toggle('on',auto[key]); | |
| if(key==='part') try{ localStorage.setItem('a25d_part',auto.part?'1':'0'); }catch(err){} | |
| } | |
| function buildTuningExport(){ | |
| return { | |
| schema:'anime2.5drig-tuning', | |
| version:1, | |
| exportedAt:new Date().toISOString(), | |
| params:currentSliderParams(), | |
| options:{part:auto.part, profileEnabled:profEnabled, chestEnabled:chestEnabled}, | |
| profile:{points:profPoints.map(p=>({v:p.v,z:p.z}))}, | |
| chest:{points:chestPoints.map(p=>({v:p.v,z:p.z}))} | |
| }; | |
| } | |
| function applyTuningImport(data){ | |
| if(!data||typeof data!=='object') throw new Error('JSONの形式が違います'); | |
| const params=(data.params&&typeof data.params==='object')?data.params:data; | |
| const num=(v,fallback)=>{ const n=parseFloat(v); return Number.isFinite(n)?n:fallback; }; | |
| let count=0; | |
| for(const id in sliders){ | |
| const key=sliders[id], el=document.getElementById(id); | |
| if(params[key]===undefined) continue; | |
| const n=parseFloat(params[key]); | |
| if(!Number.isFinite(n)) continue; | |
| const mn=parseFloat(el.min), mx=parseFloat(el.max); | |
| setSlider(key, clamp(n,mn,mx)); count++; | |
| } | |
| if(data.options&&data.options.part!==undefined) setModelToggle('tgPart','part',data.options.part); | |
| if(data.options&&data.options.profileEnabled!==undefined){ | |
| profEnabled=!!data.options.profileEnabled; tgProfCurve.classList.toggle('on',profEnabled); | |
| persistProfile(); | |
| } | |
| if(data.profile&&Array.isArray(data.profile.points)&&data.profile.points.length===PROF_DEFAULTS.length){ | |
| profPoints=data.profile.points.map((p,i)=>({v:clamp(num(p.v,PROF_DEFAULTS[i].v),0,1),z:clamp(num(p.z,PROF_DEFAULTS[i].z),-0.4,0.8)})); | |
| rebuildProfLUT(); drawProfCv(); syncProfToSliders(); persistProfile(); | |
| } | |
| if(data.options&&data.options.chestEnabled!==undefined){ | |
| chestEnabled=!!data.options.chestEnabled; tgChestCurve.classList.toggle('on',chestEnabled); | |
| persistChestProfile(); | |
| } | |
| if(data.chest&&Array.isArray(data.chest.points)&&data.chest.points.length===CHEST_DEFAULTS.length){ | |
| chestPoints=data.chest.points.map((p,i)=>({v:clamp(num(p.v,CHEST_DEFAULTS[i].v),0,1),z:clamp(num(p.z,CHEST_DEFAULTS[i].z),-0.4,0.8)})); | |
| rebuildChestLUT(); drawChestCv(); persistChestProfile(); | |
| } | |
| return count; | |
| } | |
| document.getElementById('btnExportTune').addEventListener('click',()=>{ | |
| const st=document.getElementById('utilStatus'); | |
| try{ | |
| const blob=new Blob([JSON.stringify(buildTuningExport(),null,2)],{type:'application/json'}); | |
| const a=document.createElement('a'); | |
| const stamp=new Date().toISOString().replace(/[:.]/g,'-').slice(0,19); | |
| a.href=URL.createObjectURL(blob); a.download='anime25d_tuning_'+stamp+'.json'; a.click(); | |
| setTimeout(()=>URL.revokeObjectURL(a.href),5000); | |
| st.textContent='調整値JSONを書き出しました'; | |
| }catch(err){ st.textContent='調整値の書き出しエラー: '+err.message; } | |
| }); | |
| document.getElementById('btnImportTune').addEventListener('click',()=>{ | |
| const inp=document.getElementById('tuneInput'); | |
| inp.value=''; inp.click(); | |
| }); | |
| document.getElementById('tuneInput').addEventListener('change',async ev=>{ | |
| const st=document.getElementById('utilStatus'), f=ev.target.files&&ev.target.files[0]; | |
| if(!f) return; | |
| try{ | |
| const data=JSON.parse(await f.text()); | |
| const count=applyTuningImport(data); | |
| st.textContent='調整値JSONを読み込みました('+count+'項目)'; | |
| }catch(err){ st.textContent='調整値の読み込みエラー: '+err.message; } | |
| }); | |
| let rec=null, recChunks=[]; | |
| document.getElementById('btnRecord').addEventListener('click',()=>{ | |
| const btn=document.getElementById('btnRecord'), st=document.getElementById('utilStatus'); | |
| if(rec&&rec.state==='recording'){ rec.stop(); return; } | |
| if(!cv.captureStream||typeof MediaRecorder==='undefined'){ | |
| st.textContent='このブラウザはキャンバス録画に対応していません'; | |
| return; | |
| } | |
| try{ | |
| const stream=cv.captureStream(30); | |
| const mime=MediaRecorder.isTypeSupported('video/webm;codecs=vp9')?'video/webm;codecs=vp9':'video/webm'; | |
| recChunks=[]; | |
| rec=new MediaRecorder(stream,{mimeType:mime}); | |
| rec.ondataavailable=e=>{ if(e.data&&e.data.size) recChunks.push(e.data); }; | |
| rec.onstop=()=>{ | |
| stream.getTracks().forEach(t=>t.stop()); | |
| const blob=new Blob(recChunks,{type:'video/webm'}); | |
| const a=document.createElement('a'); | |
| const stamp=new Date().toISOString().replace(/[:.]/g,'-').slice(0,19); | |
| a.href=URL.createObjectURL(blob); a.download='anime25d_capture_'+stamp+'.webm'; a.click(); | |
| setTimeout(()=>URL.revokeObjectURL(a.href),5000); | |
| btn.textContent='録画開始'; btn.classList.remove('active'); | |
| st.textContent='録画を保存しました(キャンバスのみ)'; | |
| rec=null; recChunks=[]; | |
| }; | |
| rec.start(); | |
| btn.textContent='録画停止'; btn.classList.add('active'); | |
| st.textContent='録画中…もう一度押すと保存します(キャンバスのみ)'; | |
| }catch(err){ | |
| st.textContent='録画開始エラー: '+err.message; | |
| rec=null; recChunks=[]; | |
| } | |
| }); | |
| 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 ---------- | |
| 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; | |
| const rndSm={ax:0,ay:0,az:0,bd:0,ex:0,ey:0}; // eased copy of rnd so moves glide instead of snapping | |
| 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)} | |
| // The user-authored rectangle is a fixed scalp anchor, not a root-to-tip range. | |
| // Vertices inside it always use the face shell; only a small OUTSIDE feather blends | |
| // into the floating hair shell, so the true hairline never shifts as a range changes. | |
| function frontHairPinEdge(x,y,cx,cy,halfW,halfH){ | |
| return Math.max((Math.abs(x-cx)-halfW)/HEAD.rx,(Math.abs(y-cy)-halfH)/HEAD.ry); | |
| } | |
| function frontHairPinWeight(x,y,cx,cy,halfW,halfH,e){ | |
| const ox=Math.max(0,Math.abs(x-cx)-halfW)/HEAD.rx; | |
| const oy=Math.max(0,Math.abs(y-cy)-halfH)/HEAD.ry; | |
| const outside=Math.hypot(ox,oy), feather=e.pinFeather; | |
| if(feather<=0.001) return outside<=1e-6?1:0; | |
| return 1-smooth(outside/feather); | |
| } | |
| 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'); | |
| const useProf=profEnabled; | |
| const faceInvH=1/Math.max(1,profY1-profY0); | |
| // hair layers ride their own scalp shell (HAIR) and skip all face-profile terms: | |
| // front hair + headwear float above the forehead, back hair recedes behind the head | |
| const hairMode=(bn==='front hair'||bn==='headwear')?'front':(bn==='back hair'?'back':null); | |
| const ELL=hairMode?HAIR:HEAD; | |
| // 生え際固定: the user-authored rectangle is evaluated in the unrotated model space, | |
| // then its contents are projected on the HEAD shell with no hair gap or bulge. | |
| const pinCx=HEAD.cx+e.pinX*HEAD.rx, pinCy=HEAD.cy+e.pinY*HEAD.ry; | |
| const pinHw=e.pinW*HEAD.rx, pinHh=e.pinH*HEAD.ry; | |
| const doPin=isFH&&auto.part; // OFF = legacy full-bangs float | |
| const crownStartY=HEAD.cy-HEAD.ry*0.18, crownSpan=Math.max(1,HEAD.ry*0.62); | |
| // body 3D shell: topwear/bottomwear ride a vertical elliptic cylinder (not neck — | |
| // it bridges to the head; not handwear — arms on a cylinder look wrong). Only the | |
| // *difference* between the yawed and zero-yaw projections is added, so at rest | |
| // (angleX=0, body=0) the render is pixel-identical to the non-shell pipeline. | |
| const wB=(bn==='topwear'||bn==='bottomwear')?e.shell*e.bodyShell:0; | |
| const yawB=(e.yawB!==undefined)?e.yawB:(e.angleX*0.45*e.bodyYaw+e.body*0.10); // low-passed in tick | |
| const cyb=Math.cos(yawB), syb=Math.sin(yawB); | |
| const fB=BODY?BODY.rz*6:1; | |
| const bustCy=CHEST?CHEST.cy+e.bustY*70*FS:0; | |
| const isTop=(bn==='topwear'); | |
| const useChestProf=chestEnabled; | |
| const bodyInvH=BODY?1/Math.max(1,BODY.y1-BODY.y0):1; | |
| // two breast masses centred on the nipple x-positions (offset from chest centre) | |
| const breastW=CHEST?CHEST.rx*0.40:1; | |
| // per-side nipple x (offset from chest centre, fraction of CHEST.rx; default ~ear line) | |
| const nipL=CHEST?CHEST.cx-e.nipXL*CHEST.rx:0, nipR=CHEST?CHEST.cx+e.nipXR*CHEST.rx:0; | |
| // vertical influence range of the breast bulge: 胸上(top) → 下乳(bottom) of the chest curve | |
| const chTop=useChestProf?chestPoints[1].v:0, chBot=useChestProf?chestPoints[3].v:1; | |
| const chSoft=clamp((chBot-chTop)*0.22,0.02,0.08); | |
| for(let k=0;k<n;k+=2){ | |
| let x=b[k], y=b[k+1]; | |
| const vi=k>>1; | |
| // --- 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; } | |
| 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; // 形(笑い)は閉じ口には適用しない | |
| } | |
| if(bn==='face' && y>A.mouth.cy){ | |
| y += mo*6*FS*smooth((y-A.mouth.cy)/(A.face.y1-A.mouth.cy)); | |
| } | |
| // --- head transform --- | |
| // Keep this per-vertex value for the later bang-block and spring passes. Those | |
| // passes run after shell projection, so they must also be disabled in a fixed scalp area. | |
| let pinWeight=0; | |
| 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; | |
| // 生え際固定: membership uses the base mesh so the authored rectangle remains fixed | |
| // to the head. Computed here because BOTH motion paths (A and B) must respect it. | |
| let wR=0; | |
| if(doPin){ | |
| wR=frontHairPinWeight(b[k],b[k+1],pinCx,pinCy,pinHw,pinHh,e); | |
| } | |
| pinWeight=wR; | |
| // the pinned region moves at the face's depth (1.0), not the hair slot (1.28) — | |
| // otherwise the legacy-parallax share of the blend still drags it off the forehead | |
| const dd=L.depth+(1-L.depth)*wR; | |
| // A) legacy parallax + shear | |
| const px = x + hw*FS*( e.angleX*(14+40*(dd-1)) + e.angleX*(NP.cy-y)*0.028 ); | |
| const py = y + hw*FS*( -e.angleY*(9+30*(dd-1)) - e.angleY*(dd-1)*(y-FC.y)*0.05 ); | |
| // B) 2.5D shell: lift vertex onto head ellipsoid, rotate in 3D, reproject | |
| const wS = e.shell*hw; | |
| if(wS>0.001){ | |
| const u=(x-ELL.cx)/ELL.rx, v=(y-ELL.cy)/ELL.ry; | |
| const r2=u*u+v*v; | |
| const ellip=Math.sqrt(Math.max(0,1-Math.min(1,r2))); | |
| const crown=isFH?smooth((crownStartY-y)/crownSpan)*Math.sqrt(Math.max(0,1-u*u)):0; | |
| let zs; | |
| if(hairMode==='front'){ | |
| // scalp shell + explicit gap: bangs/hats float above the forehead; | |
| // ふくらみ scales the dome (0 = flat card at the gap height) | |
| zs = e.hairBulge*ellip + e.hairGap; | |
| } else if(hairMode==='back'){ | |
| // rim stays near z≈0 (silhouette rotates like the head rim); the hidden | |
| // center sits deepest — the asymmetric swing reads as hair-mass thickness | |
| zs = -e.backDepth*ellip - 0.05; | |
| } else if(useProf){ | |
| // 横顔カーブ: draggable side-profile LUT replaces the nose/chin/E-line gaussians | |
| const pv=(y-profY0)*faceInvH; | |
| const pdx=(x-HEAD.cx)/(HEAD.nose.sx*1.9); | |
| zs = ellip + profLUTLookup(pv)*Math.exp(-pdx*pdx) + (dd-1)*0.55; | |
| } else { | |
| // ellipsoid z + nose-tip bump, continuous at the rim; depth slots become radial offsets | |
| const ndx=(x-HEAD.nose.cx)/HEAD.nose.sx, ndy=(y-HEAD.nose.cy)/HEAD.nose.sy; | |
| const cdx=(x-HEAD.chin.cx)/HEAD.chin.sx, cdy=(y-HEAD.chin.cy)/HEAD.chin.sy; | |
| // E-line: vertical ridge between nose tip and chin tip; + | |
| // pushes the mouth area out (convex), - tucks it behind the line (sharp anime profile) | |
| let elr=0; | |
| const et=(y-HEAD.nose.cy)/Math.max(1,HEAD.chin.cy-HEAD.nose.cy); | |
| if(et>0&&et<1) elr=Math.sin(Math.PI*et)*Math.exp(-ndx*ndx); | |
| zs = ellip | |
| + e.noseAmp*Math.exp(-(ndx*ndx+ndy*ndy)) | |
| + e.chinAmp*Math.exp(-(cdx*cdx+cdy*cdy)) | |
| + e.elineAmp*elr | |
| + (dd-1)*0.55; | |
| } | |
| const yaw=e.angleX*0.45, pit=e.angleY*0.32; | |
| const cyw=Math.cos(yaw), syw=Math.sin(yaw); | |
| const cpt=Math.cos(pit), spt=Math.sin(pit); | |
| let X=u*ELL.rx, Y=v*ELL.ry, Z=zs*ELL.rz; | |
| const X2=X*cyw+Z*syw, Z2=-X*syw+Z*cyw; // yaw around vertical axis | |
| const Y2=Y*cpt-Z2*spt, Z3=Y*spt+Z2*cpt; // pitch around horizontal axis | |
| const f=ELL.rz*5.0, sc=f/(f-Z3*0.5); // damped perspective | |
| let sx=ELL.cx+X2*sc, sy=ELL.cy+Y2*sc; | |
| if(crown>0.001 && e.fhCrownRound>0.001){ | |
| // 頭頂回り込み: do not add Z depth (that protrudes toward the viewer). | |
| // Instead, the upper bangs slide slightly toward the crown in screen space. | |
| const p=clamp(e.fhCrownRound*crown*0.30*(1-wR),0,0.60); | |
| const crownX=HEAD.cx+(x-HEAD.cx)*0.55, crownY=HEAD.cy-HEAD.ry*0.88; | |
| sx += (crownX-sx)*p; sy += (crownY-sy)*p; | |
| } | |
| if(wR>0.001){ | |
| // reproject this vertex as if it were face skin (HEAD shell, no gap/bulge), | |
| // matching the profile curve so the pinned region tracks the forehead exactly. | |
| // Difference-only (like the body shell): the pin adds the scalp's rotation | |
| // DELTA to the hair's rest projection, so the frontal render stays identical | |
| const uH=(x-HEAD.cx)/HEAD.rx, vH=(y-HEAD.cy)/HEAD.ry; | |
| const eH=Math.sqrt(Math.max(0,1-Math.min(1,uH*uH+vH*vH))); | |
| let zH=eH; | |
| if(useProf){ | |
| const pv=(y-profY0)*faceInvH; | |
| const pdx=(x-HEAD.cx)/(HEAD.nose.sx*1.9); | |
| zH=eH+profLUTLookup(pv)*Math.exp(-pdx*pdx); | |
| } | |
| const XH=uH*HEAD.rx, YH=vH*HEAD.ry, ZH=zH*HEAD.rz; | |
| const XH2=XH*cyw+ZH*syw, ZH2=-XH*syw+ZH*cyw; | |
| const YH2=YH*cpt-ZH2*spt, ZH3=YH*spt+ZH2*cpt; | |
| const fH=HEAD.rz*5.0, scH=fH/(fH-ZH3*0.5), sc0H=fH/(fH-ZH*0.5); | |
| const sc0=f/(f-Z*0.5); // hair shell at rest (yaw=0, pitch=0) | |
| const tx=(ELL.cx+X*sc0)+(XH2*scH-XH*sc0H); // hair rest + scalp motion delta | |
| const ty=(ELL.cy+Y*sc0)+(YH2*scH-YH*sc0H); | |
| sx+=(tx-sx)*wR; sy+=(ty-sy)*wR; | |
| } | |
| x=px+(sx-px)*wS; y=py+(sy-py)*wS; | |
| } else { x=px; y=py; } | |
| } | |
| // --- 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-bustCy)/CHEST.ry; | |
| y += bounce.dy*e.bust*Math.exp(-gx*gx-gy*gy); | |
| } | |
| // --- body 3D shell (yaw-difference only; see hoist above) --- | |
| if(wB>0.001){ | |
| const ub=(x-BODY.cx)/BODY.rx; | |
| let zb=Math.sqrt(Math.max(0,1-Math.min(1,ub*ub))); | |
| let silShift=0; // near-side breast pushing the outer body silhouette outward under rotation | |
| if(useChestProf){ // 胸カーブ: two breast masses at the nipples protrude forward per the curve | |
| const vB=clamp((y-BODY.y0)*bodyInvH,0,1); | |
| // confine the bulge to the 胸上→下乳 band so those points set its vertical extent; | |
| // the bulge swells slightly with the breath cycle so the chest reads as breathing | |
| const win=smooth((vB-chTop)/chSoft)*smooth((chBot-vB)/chSoft); | |
| const cl=chestLUTLookup(vB)*win*(1+e.breath*0.08); | |
| // localized +z bumps at ±nipple (NOT a uniform cross-section swell): the ribcage | |
| // keeps the plain cylinder while the breasts alone bulge forward and lead the turn | |
| const bl=(x-nipL)/breastW, br=(x-nipR)/breastW; | |
| // near breast (side turning toward the viewer) protrudes with 張り出し strength, | |
| // the far breast is pulled with 引っ張り strength. dir saturates quickly so even a | |
| // small body yaw reads strongly; at rest (syb=0) dir=0 → both gains 1, identity kept. | |
| const dir=clamp(syb*4,-1,1); | |
| const gainL=1+e.chestNear*Math.max(0,-dir)+e.chestFar*Math.max(0,dir); | |
| const gainR=1+e.chestNear*Math.max(0, dir)+e.chestFar*Math.max(0,-dir); | |
| // central control point: fills the sternum/cleavage so tops with soft fabric move as one | |
| // mass; set to 0 for structured tops (plackets/buttons) where the centre stays flat | |
| const bc=(x-CHEST.cx)/breastW; | |
| const breast=gainL*Math.exp(-bl*bl)+gainR*Math.exp(-br*br)+e.chestCenter*Math.exp(-bc*bc); | |
| zb += cl*breast; | |
| if(isTop){ | |
| // silhouette bulge: on the near side (side*dir>0) the protruding breast pushes the | |
| // outer contour out. Windowed to the outer breast flank (spares the centre and the | |
| // sleeve past ub~1.25) for a rounded bulge; scales with rotation, 0 when frontal. | |
| const side=ub>=0?1:-1, au=Math.abs(ub); | |
| const wEdge=smooth((au-0.2)/0.5)*smooth((1.25-au)/0.35); | |
| silShift = side*cl*Math.max(0,side*dir)*wEdge*e.chestNear*BODY.rx*0.20; | |
| } | |
| } | |
| const X=ub*BODY.rx, Z=zb*BODY.rz; | |
| const X2=X*cyb+Z*syb, Z2=-X*syb+Z*cyb; // yaw around the neck axis | |
| const scR=fB/(fB-Z2*0.5), sc0=fB/(fB-Z*0.5); // damped perspective (rotated vs zero-yaw) | |
| x += (X2*scR-X*sc0)*wB + silShift*wB; | |
| } | |
| // --- 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*(1-pinWeight); | |
| 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]; | |
| // The rectangle has already been projected with the face. Do not add any | |
| // strand motion back onto it; the feather blends motion in only outside the pin. | |
| const amp=Math.pow(u,isFH?1.8:2.1)*(isFH?e.fhAmp:e.physAmp)*(1-pinWeight); | |
| 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); | |
| // pose playback: eased move (1.1s) to next pose, then hold (1.3-2.7s), loop. | |
| // idle/blink/talk still layer on top so held poses keep breathing. | |
| if(posePlay.on&&poses.length){ | |
| if(!posePlay.to) nextPose(now); | |
| const elp=(now-posePlay.t0)/1000, TT=1.1; | |
| if(posePlay.phase==='move'){ | |
| const s=smooth(elp/TT); | |
| for(const k in posePlay.to) if(k in tgt&&posePlay.from[k]!==undefined) | |
| tgt[k]=posePlay.from[k]+(posePlay.to[k]-posePlay.from[k])*s; | |
| if(elp>=TT){ posePlay.phase='hold'; posePlay.t0=now; } | |
| } else { | |
| for(const k in posePlay.to) if(k in tgt) tgt[k]=posePlay.to[k]; | |
| if(elp>=1.3+posePlay.jit) nextPose(now); | |
| } | |
| } | |
| 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){ | |
| // ランダムの速さ: slower = longer holds between picks AND gentler glide toward each pose | |
| // (cur.rndSpeed is the same smoothed value e carries later in the frame) | |
| if(now>nextRnd){ nextRnd=now+(1400+Math.random()*2600)/Math.max(0.25,cur.rndSpeed); | |
| 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; } | |
| for(const k in rndSm) rndSm[k]+=(rnd[k]-rndSm[k])*Math.min(1,dt*4*cur.rndSpeed); | |
| tgt.angleX=clamp(tgt.angleX+rndSm.ax,-1,1); tgt.angleY=clamp(tgt.angleY+rndSm.ay,-1,1); | |
| tgt.angleZ=clamp(tgt.angleZ+rndSm.az,-1,1); tgt.body=clamp(tgt.body+rndSm.bd,-1,1); | |
| tgt.eyeX=clamp(tgt.eyeX+rndSm.ex,-1,1); tgt.eyeY=clamp(tgt.eyeY+rndSm.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 | |
| // torso yaw lags the head (~0.4s) so idle head sway becomes a gentle body sway | |
| // instead of the chest bump twitching left-right | |
| yawBState+=((e.angleX*0.45*e.bodyYaw+e.body*0.10)-yawBState)*Math.min(1,dt*2.5); | |
| e.yawB=yawBState; | |
| // 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 — 揺れ減衰: 0 = original springy cc=4.2 (ratio ~0.18), | |
| // 1 ≈ near-critical (critical for kk=140 is 2*sqrt(140)≈23.7) | |
| { const bustTgt=(e.breath*3.0 - e.angleY*6.0 + e.body*4.0)*FS; | |
| const kk=140, cc=4.2+e.bustDamp*16; | |
| 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); | |
| } | |
| } | |
| // --- debug: deformed mesh wireframe overlay (same GL coords as the textured pass) --- | |
| const showPartViz=auto.partViz||now<pinPreviewUntil; | |
| if(auto.mesh||showPartViz){ | |
| gl.useProgram(progLine); | |
| gl.disable(gl.BLEND); // solid lines, visible over any background | |
| gl.uniform2f(locLRes,CW,CH); | |
| gl.enableVertexAttribArray(locLPos); | |
| for(const L of layers){ | |
| const fa=fadeAlpha(L,e); | |
| if(fa<0.004 && !(L.fade==='eyeOpen'&&L.name.indexOf('eyewhite')===0)) continue; | |
| const isFHL=(L.bn==='front hair'||L.bn==='headwear'), isBHL=(L.bn==='back hair'); | |
| if(!auto.mesh && !(L.bn==='front hair')) continue; // 密着域表示 only: draw just the bangs mesh | |
| // part colours: front hair/headwear=orange, back hair=blue, everything else=green | |
| if(isFHL) gl.uniform4f(locLColor,1.0,0.62,0.1,1.0); | |
| else if(isBHL) gl.uniform4f(locLColor,0.3,0.55,1.0,1.0); | |
| else gl.uniform4f(locLColor,0.15,1.0,0.35,1.0); | |
| gl.bindBuffer(gl.ARRAY_BUFFER,L.vboPos); // already holds this frame's L.cur | |
| gl.vertexAttribPointer(locLPos,2,gl.FLOAT,false,0,0); | |
| gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER,L.lbo); | |
| gl.drawElements(gl.LINES,L.nLine,gl.UNSIGNED_SHORT,0); | |
| } | |
| // 密着域マーカー: draw the user-authored rectangle's edge on the deformed mesh. | |
| // It flashes briefly while any rectangle control is dragged. | |
| if(showPartViz&&A&&HEAD){ | |
| const pinCx=HEAD.cx+e.pinX*HEAD.rx, pinCy=HEAD.cy+e.pinY*HEAD.ry; | |
| const pinHw=e.pinW*HEAD.rx, pinHh=e.pinH*HEAD.ry; | |
| const edge=[]; | |
| for(const L of layers){ | |
| if(L.bn!=='front hair') continue; | |
| const cols=L.nx+1; | |
| const cross=(ia,ib)=>{ | |
| const ax=L.base[ia*2], ay=L.base[ia*2+1], bx=L.base[ib*2], by=L.base[ib*2+1]; | |
| const fa=frontHairPinEdge(ax,ay,pinCx,pinCy,pinHw,pinHh); | |
| const fb=frontHairPinEdge(bx,by,pinCx,pinCy,pinHw,pinHh); | |
| if((fa<0&&fb>0)||(fa>0&&fb<0)){ | |
| const t=fa/(fa-fb), oa=ia*2, ob=ib*2; | |
| edge.push(L.cur[oa]+(L.cur[ob]-L.cur[oa])*t,L.cur[oa+1]+(L.cur[ob+1]-L.cur[oa+1])*t); | |
| } | |
| }; | |
| for(let j=0;j<=L.ny;j++) for(let i=0;i<=L.nx;i++){ | |
| const v=j*cols+i; | |
| if(i<L.nx) cross(v,v+1); | |
| if(j<L.ny) cross(v,v+cols); | |
| } | |
| } | |
| gl.uniform4f(locLColor,1.0,0.2,0.9,1.0); | |
| gl.bindBuffer(gl.ARRAY_BUFFER,markVbo); | |
| gl.bufferData(gl.ARRAY_BUFFER,new Float32Array(edge),gl.DYNAMIC_DRAW); | |
| gl.vertexAttribPointer(locLPos,2,gl.FLOAT,false,0,0); | |
| gl.drawArrays(gl.POINTS,0,edge.length/2); | |
| } | |
| // red curve-point markers (base model coords: horizontal line at each point's height + dots) | |
| if(auto.mesh&&A){ | |
| const mk=[], pts=[]; | |
| const fh=profY1-profY0; | |
| for(const p of profPoints){ // 横顔カーブ: 額/鼻根/鼻先/上唇/顎 | |
| const y=profY0+p.v*fh; | |
| mk.push(A.face.x0,y, A.face.x1,y); pts.push(A.face.cx,y); | |
| } | |
| if(BODY&&CHEST){ // 胸カーブ: 首元/胸上/胸先/下乳/腰 | |
| const bh=BODY.y1-BODY.y0, bw=CHEST.rx*0.40; | |
| const nl=CHEST.cx-e.nipXL*CHEST.rx, nr=CHEST.cx+e.nipXR*CHEST.rx; | |
| for(const p of chestPoints){ | |
| const y=BODY.y0+p.v*bh; | |
| mk.push(nl-bw,y, nr+bw,y); pts.push(nl,y, nr,y, CHEST.cx,y); // + center column | |
| } | |
| } | |
| gl.uniform4f(locLColor,1.0,0.15,0.2,1.0); // red | |
| gl.bindBuffer(gl.ARRAY_BUFFER,markVbo); | |
| gl.bufferData(gl.ARRAY_BUFFER,new Float32Array(mk),gl.DYNAMIC_DRAW); | |
| gl.vertexAttribPointer(locLPos,2,gl.FLOAT,false,0,0); | |
| gl.drawArrays(gl.LINES,0,mk.length/2); | |
| gl.bufferData(gl.ARRAY_BUFFER,new Float32Array(pts),gl.DYNAMIC_DRAW); | |
| gl.vertexAttribPointer(locLPos,2,gl.FLOAT,false,0,0); | |
| gl.drawArrays(gl.POINTS,0,pts.length/2); | |
| } | |
| gl.enable(gl.BLEND); | |
| gl.useProgram(prog); gl.uniform2f(locRes,CW,CH); | |
| gl.enableVertexAttribArray(locPos); gl.enableVertexAttribArray(locUV); | |
| } | |
| 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:
- 92.6 kB
- Xet hash:
- 9662ece319ea3745bc4512921d3047630b77650eaefeccf7364a6839c7208094
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.