mcuo's picture
download
raw
92.6 kB
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Anime2.5DRig — PSDドロップで動くアバター</title>
<style>
:root{--bg:#14151c;--panel:#1d1f2a;--line:#2c2f3e;--txt:#dfe2ee;--sub:#8d93ad;--acc:#7aa2ff;}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--txt);font-family:"Hiragino Sans","Yu Gothic UI",system-ui,sans-serif;height:100vh;display:flex;flex-direction:column;overflow:hidden}
header{padding:10px 16px;border-bottom:1px solid var(--line);display:flex;align-items:baseline;gap:12px;flex-wrap:wrap}
header h1{font-size:15px;font-weight:600}
header span{font-size:11px;color:var(--sub)}
#main{flex:1;display:flex;min-height:0}
#stage{flex:1;display:flex;align-items:center;justify-content:center;min-width:0;position:relative}
#stage.checker{background:repeating-conic-gradient(#23242e 0 25%,#1a1b23 0 50%) 0 0/24px 24px}
#stage.green{background:#00b140}
canvas{max-width:100%;max-height:100%;display:block}
#fps{position:absolute;left:10px;top:8px;font-size:11px;color:var(--sub)}
#rigInfo{position:absolute;left:10px;bottom:8px;font-size:10px;color:var(--sub);max-width:60%;line-height:1.5}
#drop{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:14px;background:rgba(20,21,28,.88);z-index:5;text-align:center;padding:20px}
#drop.hidden{display:none}
#drop h2{font-size:18px;font-weight:600}
#drop p{font-size:12px;color:var(--sub);line-height:1.8}
#drop.dragover{outline:3px dashed var(--acc);outline-offset:-16px}
#panel{width:300px;background:var(--panel);border-left:1px solid var(--line);overflow-y:auto;padding:14px 16px 40px;flex-shrink:0}
.sec{margin-bottom:18px}
.sec h2{font-size:11px;letter-spacing:.08em;color:var(--sub);text-transform:uppercase;margin-bottom:8px;border-bottom:1px solid var(--line);padding-bottom:4px}
.row{display:flex;align-items:center;gap:8px;margin:7px 0}
.row label{font-size:12px;width:86px;flex-shrink:0}
.row input[type=range]{flex:1;accent-color:var(--acc);height:14px}
.row .val{font-size:10px;color:var(--sub);width:34px;text-align:right}
.toggles{display:flex;flex-wrap:wrap;gap:6px}
.tg{font-size:12px;padding:5px 10px;border:1px solid var(--line);border-radius:20px;cursor:pointer;user-select:none;color:var(--sub)}
.tg.on{border-color:var(--acc);color:var(--acc);background:rgba(122,162,255,.1)}
.btns{display:flex;gap:6px;flex-wrap:wrap}
.btn{font-size:12px;padding:6px 14px;background:#262939;border:1px solid var(--line);border-radius:6px;color:var(--txt);cursor:pointer}
.btn:hover{border-color:var(--acc)}
.btn.primary{background:var(--acc);color:#10121a;font-weight:600;border-color:var(--acc)}
#layers{font-size:11px;color:var(--sub);line-height:1.7}
#layers b{color:var(--txt);font-weight:500}
.tag{font-size:9px;border:1px solid var(--line);border-radius:4px;padding:0 4px;margin-left:4px;color:var(--acc)}
/* ---- monochrome + deep red theme ---- */
:root{--bg:#0d0d0f;--panel:#151517;--line:#29292e;--txt:#ececee;--sub:#87878f;--acc:#a01030;--acc2:#d43b55;}
body{background:radial-gradient(1200px 600px at 70% -10%, rgba(160,16,48,.09), transparent 60%), var(--bg);}
header{position:relative}
header::after{content:'';position:absolute;left:0;bottom:-1px;width:140px;height:2px;
background:linear-gradient(90deg,var(--acc),transparent);}
header h1{letter-spacing:.05em}
.tg,.btn,input[type=range],.sec h2{transition:all .18s ease}
.tg:hover{border-color:var(--acc);color:var(--acc2)}
.tg.on{border-color:var(--acc);color:var(--acc2);background:rgba(160,16,48,.14);box-shadow:inset 0 0 8px rgba(160,16,48,.25)}
.btn{background:#1c1c1f}
.btn:hover{border-color:var(--acc);color:var(--acc2);transform:translateY(-1px);box-shadow:0 3px 12px rgba(160,16,48,.28)}
.btn:active{transform:translateY(0)}
.btn.active{background:var(--acc);border-color:var(--acc);color:#fff;box-shadow:0 0 12px rgba(212,59,85,.45)}
.btn.primary{background:var(--acc);border-color:var(--acc);color:#fff}
.btn.primary:hover{background:var(--acc2);color:#fff}
.sec h2{border-left:3px solid var(--acc);padding-left:7px}
.tag{color:var(--acc2);border-color:#3a3a40}
#panel{scrollbar-width:thin;scrollbar-color:var(--acc) transparent}
#drop.dragover{outline-color:var(--acc)}
#readmeOverlay{position:fixed;inset:0;background:rgba(0,0,0,.65);z-index:50;display:none;align-items:center;justify-content:center;padding:30px}
#readmeOverlay.show{display:flex}
#readmeWin{background:var(--panel);border:1px solid var(--line);border-top:3px solid var(--acc);border-radius:10px;
max-width:760px;width:100%;max-height:85vh;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,.6)}
#readmeHead{display:flex;align-items:center;padding:10px 16px;border-bottom:1px solid var(--line)}
#readmeHead b{font-size:13px;letter-spacing:.05em}
#readmeClose{margin-left:auto;cursor:pointer;color:var(--sub);font-size:18px;padding:0 6px;transition:color .15s}
#readmeClose:hover{color:var(--acc2)}
#readmeBody{overflow-y:auto;padding:18px 22px;font-size:13px;line-height:1.9;scrollbar-width:thin;scrollbar-color:var(--acc) transparent}
#readmeBody h1{font-size:18px;margin:10px 0;color:#fff;border-bottom:1px solid var(--line);padding-bottom:6px}
#readmeBody h2{font-size:15px;margin:16px 0 6px;color:var(--acc2)}
#readmeBody h3{font-size:13px;margin:12px 0 4px}
#readmeBody p{margin:4px 0}
#readmeBody ul{margin:6px 0 6px 20px}
#readmeBody code{background:#222226;border-radius:4px;padding:1px 5px;font-size:12px;color:var(--acc2)}
#readmeBody pre{background:#141416;border:1px solid var(--line);border-radius:6px;padding:10px;margin:8px 0;overflow-x:auto}
#readmeBody pre code{background:none;color:var(--txt);padding:0}
#readmeBody table{border-collapse:collapse;margin:10px 0;width:100%;font-size:12px}
#readmeBody th{background:#1c1c1f;color:var(--acc2);text-align:left}
#readmeBody th,#readmeBody td{border:1px solid var(--line);padding:5px 9px}
#readmeBody blockquote{border-left:3px solid var(--acc);padding:4px 12px;margin:8px 0;color:var(--sub);background:rgba(160,16,48,.06)}
#readmeBody a{color:var(--acc2)}
</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(){
"use strict";
const cv = document.getElementById('cv');
const gl = cv.getContext('webgl', {alpha:true, stencil:true, antialias:true, premultipliedAlpha:true});
if(!gl){ document.getElementById('stage').textContent='WebGL未対応'; return; }
// ---------- GL setup ----------
function sh(type, src){ const s=gl.createShader(type); gl.shaderSource(s,src); gl.compileShader(s);
if(!gl.getShaderParameter(s,gl.COMPILE_STATUS)) throw gl.getShaderInfoLog(s); return s; }
const prog = gl.createProgram();
gl.attachShader(prog, sh(gl.VERTEX_SHADER,
'attribute vec2 aPos; attribute vec2 aUV; uniform vec2 uRes; varying vec2 vUV;'+
'void main(){ vUV=aUV; vec2 c = aPos/uRes*2.0-1.0; gl_Position=vec4(c.x,-c.y,0.0,1.0); }'));
gl.attachShader(prog, sh(gl.FRAGMENT_SHADER,
'precision mediump float; varying vec2 vUV; uniform sampler2D uTex; uniform float uCut; uniform float uAlpha;'+
'void main(){ vec4 c=texture2D(uTex,vUV); if(c.a<uCut) discard; gl_FragColor=c*uAlpha; }'));
gl.linkProgram(prog); gl.useProgram(prog);
const locPos=gl.getAttribLocation(prog,'aPos'), locUV=gl.getAttribLocation(prog,'aUV');
const locRes=gl.getUniformLocation(prog,'uRes'), locCut=gl.getUniformLocation(prog,'uCut'), locAl=gl.getUniformLocation(prog,'uAlpha');
gl.enableVertexAttribArray(locPos); gl.enableVertexAttribArray(locUV);
gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);
// ---------- 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
const inline=s=>esc(s)
.replace(/\*\*(.+?)\*\*/g,'<b>$1</b>')
.replace(/`([^`]+)`/g,'<code>$1</code>')
.replace(/\[([^\]]+)\]\(([^)]+)\)/g,'<a href="$2" target="_blank" rel="noopener">$1</a>');
const lines=md.split(/\r?\n/);
let html='', inCode=false, inList=false, inTable=false;
for(const l of lines){
if(l.startsWith('```')){ html+=inCode?'</code></pre>':'<pre><code>'; inCode=!inCode; continue; }
if(inCode){ html+=esc(l)+'\n'; continue; }
if(/^\|/.test(l.trim())){
if(/^\|[\s\-|:]+\|?$/.test(l.trim())) continue;
const cells=l.trim().split('|').slice(1,-1).map(c=>inline(c.trim()));
if(!inTable){ html+='<table><tr>'+cells.map(c=>'<th>'+c+'</th>').join('')+'</tr>'; inTable=true; }
else html+='<tr>'+cells.map(c=>'<td>'+c+'</td>').join('')+'</tr>';
continue;
} else if(inTable){ html+='</table>'; inTable=false; }
if(/^\s*[-*] /.test(l)){ if(!inList){html+='<ul>';inList=true;}
html+='<li>'+inline(l.replace(/^\s*[-*] /,''))+'</li>'; continue; }
else if(inList){ html+='</ul>'; inList=false; }
const h=l.match(/^(#{1,4}) (.*)/);
if(h){ html+='<h'+h[1].length+'>'+inline(h[2])+'</h'+h[1].length+'>'; continue; }
if(/^> /.test(l)){ html+='<blockquote>'+inline(l.slice(2))+'</blockquote>'; continue; }
if(l.trim()!=='') html+='<p>'+inline(l)+'</p>';
}
if(inCode)html+='</code></pre>'; if(inList)html+='</ul>'; if(inTable)html+='</table>';
return html;
}
let readmeLoaded=false;
async function openReadme(){
const ov=document.getElementById('readmeOverlay');
ov.classList.add('show');
if(readmeLoaded) return;
const body=document.getElementById('readmeBody');
try{
const r=await fetch('README.md');
if(!r.ok) throw new Error('HTTP '+r.status);
body.innerHTML=mdToHtml(await r.text());
readmeLoaded=true;
}catch(err){
body.innerHTML='<p>README.md を取得できませんでした('+err.message+')。'+
'<a href="https://github.com/852wa/Anime2.5DRig#readme" target="_blank" rel="noopener">GitHubで読む</a></p>';
}
}
document.getElementById('btnReadme').addEventListener('click',openReadme);
document.getElementById('btnReadme2').addEventListener('click',openReadme);
document.getElementById('readmeClose').addEventListener('click',()=>document.getElementById('readmeOverlay').classList.remove('show'));
document.getElementById('readmeOverlay').addEventListener('click',e=>{ if(e.target.id==='readmeOverlay') e.target.classList.remove('show'); });
// ---------- mouse ----------
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.