Compare commits

...
32 changed files with 1976 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
evidence/_video/
public/bundle.js
+46
View File
@@ -0,0 +1,46 @@
# HEL-702 · 独立3D角色换装/动作/环境 技术验证包
隔离试验目录:不依赖、不修改生产 `web/`,未部署。所有结论基于本目录实际运行结果(2026-10-20)。
## 结论速览(均已实测,非设想)
| 验证项 | 结果 | 证据 |
|---|---|---|
| VRM 角色浏览器加载 | ✅ 两个模型(pixiv/VirtualCast)加载成功,152ms | `evidence/runtime-stats.json`, 截图01/09 |
| 换装(非改色) | ✅ 原始套装 ↔ 自制连帽衫+长裤(不同几何,绑定同一骨架)切换 | 截图02/03 |
| 站/坐/阅读 | ✅ 坐姿 hips=0.515m 精确对齐椅面0.46m;书本距两手各0.095m | 截图04/05 + probe 数据 |
| 环境↔角色解耦 | ✅ 昼夜+晴雨切换后 19骨四元数 maxDelta=0(程序断言) | `decouplingAssert: {pass:true}` |
| 视线/表情交互 | ✅ 视线转向镜头时眼球偏转+happy表情权重0→0.9 | 截图07/07b/07c |
| 标准动画文件播放 | ✅ test.vrma(VRMA 1.0)跨模型重定向播放 | 截图08/08b, 录屏 |
| 真实天气接口 | ✅ Open-Meteo 无Key 取得3城市气温/降水/天气码/日出日落 + 中文地理编码 | `evidence/weather/*.json` |
| 性能实测 | 67k三角/61 draw calls/27纹理/42MB JS堆;SwiftShader软渲染24fps(下界) | `runtime-stats.json` |
## 复现步骤
```bash
cd experiments/hel-702-3d-verify
npm install
npx esbuild src/main.js --bundle --format=esm --outfile=public/bundle.js
# 浏览器人工查看: 任一静态服务器指向本目录, 路由 / -> public/index.html, /assets/ -> assets/
# 自动取证(截图+录屏+数据):
node capture/capture.mjs # 完整序列(约90s, 需 playwright chromium)
node capture/capture.mjs --shot 'state={"action":"read","time":"night","weather":"rain"}' cam=main out=x.png
```
页面 URL 参数:`?model=twist|seed&cam=main|front|wide|closeup`。
运行时控制台 API:`__app.setState({outfit:'A'|'B', action:'stand'|'sit'|'read'|'vrma', time:'day'|'night', weather:'sunny'|'rain', gaze:'idle'|'camera'})`,`__app.snapshotPose()` / `__app.assertPosePreserved()`(环境切换姿态保持断言),`__app.probe()`(骨骼/道具几何数据)。
## 素材与授权(详见 assets/manifest.json)
- `VRM1_Constraint_Twist_Sample.vrm` — pixiv Inc., VRM Public License 1.0(允许改変/再配布的公开授权,内嵌元数据可在运行时读取,本页已读取展示)。来源 vrm-c/vrm-specification 官方样例仓库,sha256 见 manifest。
- `Seed-san.vrm` — VirtualCast, Inc., 同上。
- `test.vrma` — three-vrm(MIT) 仓库示例动画。
- 服装B(连帽衫+长裤)为本验证自制占位几何,只证明"同一骨架绑定两套不同服装并切换"的管线,正式服装属于后续美术施工。
## 关键设计(供正式施工沿用)
1. 环境状态(time/weather/灯光/雨)与角色状态(outfit/action/gaze)在代码层完全隔离,环境更新不触碰 `vrm` 对象 —— 天气刷新不会导致角色重建或姿势重置。
2. 姿势走 VRM normalized 人形骨架(任何 VRM 通用),目标四元数 + 指数平滑 = 动作自然过渡;坐姿按家具实测高度计算 hips 偏移。
3. 道具(书本)按帧放在两手骨骼中点并对齐角色朝向,不依赖特定模型手型。
4. 服装=骨架上的独立网格集合:原生 VRoid 模型 Tops/Bottoms/Shoes 本就是独立 skinned primitive,切换=可见性/几何集合切换,骨架不动。
5. 行为/心情接口预留:`__app.setState({gaze})` 即注意力接口雏形;八字/心情引擎接入点在 `applyState` 的角色段,本轮未接(明确标记:非真实状态引擎驱动)。
Binary file not shown.
@@ -0,0 +1,54 @@
{
"note": "HEL-702 素材清单:来源/哈希/授权(生成于 2026-10-20)",
"assets": [
{
"file": "VRM1_Constraint_Twist_Sample.vrm",
"sizeBytes": 10776032,
"sizeMB": "10.3",
"sha256": "12c2b97e95e700783a6a550dc0eee2d7880aeedccef9ae67bc4c5a2f0f2631a2",
"source": "https://github.com/vrm-c/vrm-specification/tree/master/samples/VRM1_Constraint_Twist_Sample",
"author": "pixiv Inc.",
"license": "VRM Public License 1.0",
"licenseUrl": "https://vrm.dev/licenses/1.0/",
"embeddedMeta": {
"name": "VRM1_Constraint_Twist_Sample",
"authors": ["pixiv Inc."],
"licenseUrl": "https://vrm.dev/licenses/1.0/",
"commercialUssageName": null,
"modificationName": null,
"redistributionName": null
},
"structure": "3 skinned meshes (Body 5 prims / Face 7 prims / Hair), 83 skin joints, VRoid 材质分区: Tops_01_CLOTH / Bottoms_01_CLOTH / Shoes_01_CLOTH 独立 primitive, 19/19 标准人形骨骼齐全, 57 表情形态键"
},
{
"file": "Seed-san.vrm",
"sizeBytes": 10917800,
"sizeMB": "10.4",
"sha256": "624d0d554bc205bbdc33e22a68a2c3c20edebb3e573011ead8878a65e5329b23",
"source": "https://github.com/vrm-c/vrm-specification/tree/master/samples/Seed-san",
"author": "VirtualCast, Inc.",
"license": "VRM Public License 1.0",
"licenseUrl": "https://vrm.dev/licenses/1.0/",
"embeddedMeta": {
"name": "Seed-san",
"authors": ["VirtualCast, Inc."],
"licenseUrl": "https://vrm.dev/licenses/1.0/"
},
"structure": "5 skinned meshes, wear 网格 12 个材质 primitive, 23 skin joints, 43 形态键, PBR+MToon 混合(验证 MToon 兼容性用)"
},
{
"file": "test.vrma",
"sizeBytes": 11548,
"sha256": "38d0fd12d61e896f1a970b5e358ebb41a96c8d5ee8e284496ea18f0ba1f04e7b",
"source": "https://github.com/pixiv/three-vrm/tree/dev/packages/three-vrm-animation/examples/models",
"author": "pixiv Inc. (three-vrm, MIT)",
"license": "MIT (作为 three-vrm 仓库示例文件分发)",
"content": "VRM Animation 1.0 示例:51 骨骼映射 + happy 表情 + lookAt 通道,3 动画通道(旋转/位移),用于验证标准动画文件加载与重定向"
}
],
"runtimeDeps": {
"three": "^0.170.0 (MIT)",
"@pixiv/three-vrm": "^3.4.2 (MIT)",
"@pixiv/three-vrm-animation": "^3.4.2 (MIT)"
}
}
Binary file not shown.
@@ -0,0 +1,140 @@
/* HEL-702 证据采集:headless Chromium (SwiftShader) 驱动验证页,截图 + 录屏 + 运行数据 */
import { chromium } from 'playwright';
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..');
const PUB = path.join(ROOT, 'public');
const PORT = 8931;
const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.json': 'application/json', '.vrm': 'model/gltf-binary', '.vrma': 'model/gltf-binary', '.png': 'image/png' };
const server = http.createServer((req, res) => {
const url = req.url.split('?')[0];
let p;
if (url === '/' || url === '/index.html') p = path.join(ROOT, 'public', 'index.html');
else if (url.startsWith('/assets/')) p = path.join(ROOT, url.slice(1));
else if (url === '/bundle.js') p = path.join(ROOT, 'public', 'bundle.js');
else { res.writeHead(404); res.end('nf'); return; }
fs.readFile(p, (e, buf) => {
if (e) { res.writeHead(404); res.end('nf'); return; }
res.writeHead(200, { 'content-type': MIME[path.extname(p)] || 'application/octet-stream' });
res.end(buf);
});
});
await new Promise((r) => server.listen(PORT, r));
const SHOT = process.argv.includes('--shot'); // 单镜头模式: --shot state=json cam=name out=xx.png
const arg = (k) => process.argv.slice(2).find((a) => a.startsWith(k + '='))?.slice(k.length + 1);
const outDir = path.join(ROOT, 'evidence', 'shots');
fs.mkdirSync(outDir, { recursive: true });
const browser = await chromium.launch({ args: ['--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
async function session({ video = false } = {}) {
const ctx = await browser.newContext({
viewport: { width: 960, height: 540 },
recordVideo: video ? { dir: path.join(ROOT, 'evidence', '_video'), size: { width: 960, height: 540 } } : undefined,
});
const page = await ctx.newPage();
page.on('console', (m) => { if (m.type() === 'error') console.error('[page err]', m.text()); });
page.on('pageerror', (e) => console.error('[page throw]', e.message, (e.stack?.split('\n').slice(1, 8).join('\n') || '')));
return { ctx, page };
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
if (SHOT) {
const { ctx, page } = await session();
const state = JSON.parse(arg('state') || '{}');
const cam = arg('cam') || 'main';
const model = arg('model') || 'twist';
const out = path.join(outDir, arg('out') || 'shot.png');
await page.goto(`http://localhost:${PORT}/?cam=${cam}&model=${model}`);
await page.waitForFunction(() => window.__app?.ready === true, null, { timeout: 60000 });
await sleep(600);
await page.evaluate((s) => window.__app.setState(s), state);
await sleep(arg('wait') ? parseInt(arg('wait')) : 1600);
await page.screenshot({ path: out });
const stats = await page.evaluate(() => JSON.parse(JSON.stringify(window.__stats)));
const probe = await page.evaluate(() => window.__app.probe());
console.log('shot saved:', out);
console.log('probe:', JSON.stringify(probe));
console.log('stats:', JSON.stringify({ fps: stats.fps, loadMs: stats.loadMs, info: stats.info, meta: stats.modelMeta }));
await ctx.close();
} else {
/* 完整证据序列(含录屏) */
const { ctx, page } = await session({ video: true });
await page.goto(`http://localhost:${PORT}/?cam=main&model=twist`);
await page.waitForFunction(() => window.__app?.ready === true, null, { timeout: 60000 });
await sleep(1200);
const cap = async (name, state, wait = 1500, caption) => {
await page.evaluate((c) => { const el = document.getElementById('caption'); el.textContent = c; el.style.opacity = 1; }, caption || '');
await page.evaluate((s) => window.__app.setState(s), state);
await sleep(wait);
await page.screenshot({ path: path.join(outDir, name) });
const probe = await page.evaluate(() => window.__app.probe());
globalProbes[name] = probe;
console.log('captured', name, JSON.stringify(probe));
};
const globalProbes = {};
await cap('01-meta-stand-day-sunny.png', { outfit: 'A', action: 'stand', time: 'day', weather: 'sunny', gaze: 'idle' }, 1200,
'① VRM 实时加载 · 站立 · 白天/晴(HUD 显示模型内嵌授权元数据)');
await cap('02-outfitA-front.png', { outfit: 'A', action: 'stand' }, 1500, '② 服装A:原始 Tops+Bottoms+Shoes');
await page.evaluate(() => window.__app.snapshotPose());
await cap('03-outfitB-front.png', { outfit: 'B', action: 'stand' }, 1800, '③ 服装B:自制连帽衫+长裤(不同几何,非改色)· 骨架未动');
await cap('04-sit-day.png', { action: 'sit' }, 2000, '④ 坐下:hips 对齐椅面高度 0.46m,过渡平滑');
await cap('05-read-day.png', { action: 'read', gaze: 'idle' }, 2000, '⑤ 阅读:书本绑定右手原始骨骼,视线落在书页');
await page.evaluate(() => window.__app.snapshotPose());
await cap('06-read-night-rain.png', { time: 'night', weather: 'rain' }, 2500, '⑥ 夜晚+雨:环境切换 · 姿态保持(HUD 显示断言结果)');
const decoupling = await page.evaluate(() => window.__app.assertPosePreserved());
console.log('decoupling assert:', JSON.stringify(decoupling));
await page.screenshot({ path: path.join(outDir, '06b-decouple-assert.png') });
await cap('07-gaze-camera.png', { gaze: 'camera' }, 1500, '⑦ 交互注意力:视线转向镜头 + happy 表情');
await cap('08-vrma-play.png', { action: 'vrma', gaze: 'idle' }, 2500, '⑧ 播放标准 VRMA 动画文件(跨模型重定向)');
await page.screenshot({ path: path.join(outDir, '08b-vrma-mid.png') });
/* 姿态保持 + 运行数据 */
const stats = await page.evaluate(() => JSON.parse(JSON.stringify(window.__stats)));
/* fps 稳态测量:连续采样 6s */
const fps = await page.evaluate(async () => {
await new Promise((r) => setTimeout(r, 6000));
return { fps: window.__stats.fps, info: window.__stats.info, heapMB: window.__stats.heapMB ?? null };
});
stats.steady = fps;
stats.decouplingAssert = decoupling;
stats.userAgent = await page.evaluate(() => navigator.userAgent);
stats.environment = 'headless chromium + SwiftShader software WebGL2 (无GPU沙箱的下界测量)';
stats.probes = globalProbes;
fs.writeFileSync(path.join(ROOT, 'evidence', 'runtime-stats.json'), JSON.stringify(stats, null, 2));
console.log('stats saved');
const video = page.video();
const vidPath = await video.path();
await ctx.close(); /* finalizes video */
if (vidPath && fs.existsSync(vidPath)) {
const dest = path.join(ROOT, 'evidence', 'hel-702-verify-demo.webm');
fs.copyFileSync(vidPath, dest);
console.log('video saved:', dest);
}
}
/* Seed-san 第二模型验证(管线通用性 + MToon/PBR 混合) */
{
const { ctx, page } = await session();
await page.goto(`http://localhost:${PORT}/?cam=main&model=seed`);
await page.waitForFunction(() => window.__app?.ready === true, null, { timeout: 60000 });
await sleep(1500);
await page.evaluate(() => window.__app.setState({ outfit: 'A', action: 'stand' }));
await sleep(1200);
await page.screenshot({ path: path.join(outDir, '09-seedsan-load.png') });
const meta = await page.evaluate(() => window.__stats.modelMeta);
const load = await page.evaluate(() => window.__stats.loadMs);
fs.writeFileSync(path.join(ROOT, 'evidence', 'seed-san-runtime.json'), JSON.stringify({ meta, loadMs: load }, null, 2));
console.log('seed-san ok:', JSON.stringify(meta), 'loadMs', load);
await ctx.close();
}
await browser.close();
server.close();
@@ -0,0 +1,380 @@
{
"modelMeta": {
"name": "VRM1_Constraint_Twist_Sample",
"authors": [
"pixiv Inc."
],
"licenseUrl": "https://vrm.dev/licenses/1.0/",
"commercialUssageName": "(未设置→按VRM PL 1.0默认)",
"modificationName": "(未设置→按VRM PL 1.0默认)",
"allowedUserName": "(未设置)"
},
"asset": {
"file": "VRM1_Constraint_Twist_Sample.vrm",
"sizeBytes": 10776032,
"sizeMB": "10.3",
"sha256": "12c2b97e95e700783a6a550dc0eee2d7880aeedccef9ae67bc4c5a2f0f2631a2",
"source": "https://github.com/vrm-c/vrm-specification/tree/master/samples/VRM1_Constraint_Twist_Sample",
"author": "pixiv Inc.",
"license": "VRM Public License 1.0",
"licenseUrl": "https://vrm.dev/licenses/1.0/",
"embeddedMeta": {
"name": "VRM1_Constraint_Twist_Sample",
"authors": [
"pixiv Inc."
],
"licenseUrl": "https://vrm.dev/licenses/1.0/",
"commercialUssageName": null,
"modificationName": null,
"redistributionName": null
},
"structure": "3 skinned meshes (Body 5 prims / Face 7 prims / Hair), 83 skin joints, VRoid 材质分区: Tops_01_CLOTH / Bottoms_01_CLOTH / Shoes_01_CLOTH 独立 primitive, 19/19 标准人形骨骼齐全, 57 表情形态键"
},
"loadMs": 152,
"fps": 20,
"info": {
"calls": 61,
"triangles": 67298,
"textures": 27,
"geometries": 58
},
"heapMB": 42,
"steady": {
"fps": 24,
"info": {
"calls": 61,
"triangles": 67298,
"textures": 27,
"geometries": 58
},
"heapMB": 42
},
"decouplingAssert": {
"pass": true,
"maxDelta": 0
},
"userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36",
"environment": "headless chromium + SwiftShader software WebGL2 (无GPU沙箱的下界测量)",
"probes": {
"01-meta-stand-day-sunny.png": {
"headY": 1.387,
"hipsY": 0.908,
"footMinY": 0.101,
"kneeFwd": -0.043,
"faceAngleToCamDeg": 10.9,
"bookToRHand": null,
"bookToLHand": null,
"handDist": 0.475,
"rHandPos": [
-0.55,
0.87,
0.39
],
"lHandPos": [
-0.12,
0.87,
0.18
],
"headPos": [
-0.3,
1.39,
0.34
],
"bbox": [
0.607,
1.617,
0.469
],
"eyeYaw": 491.3,
"happyWeight": 0,
"rootPos": [
-0.3,
0,
0.35
],
"rootYaw": 0.45,
"seatY": 0.46,
"outfitBBound": true
},
"02-outfitA-front.png": {
"headY": 1.386,
"hipsY": 0.908,
"footMinY": 0.101,
"kneeFwd": -0.044,
"faceAngleToCamDeg": 10.5,
"bookToRHand": null,
"bookToLHand": null,
"handDist": 0.475,
"rHandPos": [
-0.54,
0.87,
0.39
],
"lHandPos": [
-0.12,
0.87,
0.19
],
"headPos": [
-0.31,
1.39,
0.33
],
"bbox": [
0.607,
1.617,
0.469
],
"eyeYaw": 376.2,
"happyWeight": 0,
"rootPos": [
-0.3,
0,
0.35
],
"rootYaw": 0.45,
"seatY": 0.46,
"outfitBBound": true
},
"03-outfitB-front.png": {
"headY": 1.386,
"hipsY": 0.908,
"footMinY": 0.101,
"kneeFwd": -0.044,
"faceAngleToCamDeg": 10.5,
"bookToRHand": null,
"bookToLHand": null,
"handDist": 0.475,
"rHandPos": [
-0.54,
0.87,
0.39
],
"lHandPos": [
-0.11,
0.87,
0.19
],
"headPos": [
-0.31,
1.39,
0.33
],
"bbox": [
0.607,
1.617,
0.469
],
"eyeYaw": -362.9,
"happyWeight": 0,
"rootPos": [
-0.3,
0,
0.35
],
"rootYaw": 0.45,
"seatY": 0.46,
"outfitBBound": true
},
"04-sit-day.png": {
"headY": 0.995,
"hipsY": 0.515,
"footMinY": 0.01,
"kneeFwd": 0.353,
"faceAngleToCamDeg": 34.8,
"bookToRHand": null,
"bookToLHand": null,
"handDist": 0.475,
"rHandPos": [
0.41,
0.48,
-0.04
],
"lHandPos": [
0.87,
0.48,
0.1
],
"headPos": [
0.62,
1,
0.11
],
"bbox": [
0.603,
1.617,
0.666
],
"eyeYaw": -1164,
"happyWeight": 0,
"rootPos": [
0.62,
0,
0.1
],
"rootYaw": -0.3,
"seatY": 0.46,
"outfitBBound": true
},
"05-read-day.png": {
"headY": 0.989,
"hipsY": 0.515,
"footMinY": 0.01,
"kneeFwd": 0.353,
"faceAngleToCamDeg": 43.4,
"bookToRHand": 0.095,
"bookToLHand": 0.095,
"handDist": 0.186,
"rHandPos": [
0.42,
0.88,
0.44
],
"lHandPos": [
0.59,
0.88,
0.5
],
"headPos": [
0.61,
0.99,
0.14
],
"bbox": [
0.641,
1.617,
0.666
],
"eyeYaw": -1442.2,
"happyWeight": 0,
"rootPos": [
0.62,
0,
0.1
],
"rootYaw": -0.3,
"seatY": 0.46,
"outfitBBound": true
},
"06-read-night-rain.png": {
"headY": 0.989,
"hipsY": 0.515,
"footMinY": 0.01,
"kneeFwd": 0.353,
"faceAngleToCamDeg": 43.4,
"bookToRHand": 0.095,
"bookToLHand": 0.095,
"handDist": 0.186,
"rHandPos": [
0.42,
0.88,
0.44
],
"lHandPos": [
0.59,
0.88,
0.5
],
"headPos": [
0.61,
0.99,
0.14
],
"bbox": [
0.641,
1.617,
0.666
],
"eyeYaw": -838.3,
"happyWeight": 0,
"rootPos": [
0.62,
0,
0.1
],
"rootYaw": -0.3,
"seatY": 0.46,
"outfitBBound": true
},
"07-gaze-camera.png": {
"headY": 0.989,
"hipsY": 0.515,
"footMinY": 0.01,
"kneeFwd": 0.353,
"faceAngleToCamDeg": 43.4,
"bookToRHand": 0.095,
"bookToLHand": 0.095,
"handDist": 0.186,
"rHandPos": [
0.42,
0.88,
0.44
],
"lHandPos": [
0.59,
0.88,
0.5
],
"headPos": [
0.61,
0.99,
0.14
],
"bbox": [
0.641,
1.617,
0.666
],
"eyeYaw": 2191.6,
"happyWeight": 0.9,
"rootPos": [
0.62,
0,
0.1
],
"rootYaw": -0.3,
"seatY": 0.46,
"outfitBBound": true
},
"08-vrma-play.png": {
"headY": 0.989,
"hipsY": 0.515,
"footMinY": 0.01,
"kneeFwd": 0.346,
"faceAngleToCamDeg": 38.3,
"bookToRHand": null,
"bookToLHand": null,
"handDist": 0.186,
"rHandPos": [
-0.09,
0.88,
0.39
],
"lHandPos": [
0.09,
0.88,
0.39
],
"headPos": [
0,
0.99,
0.04
],
"bbox": [
0.597,
1.617,
0.598
],
"eyeYaw": 898.1,
"happyWeight": 0,
"rootPos": [
0,
0,
0
],
"rootYaw": 0,
"seatY": 0.46,
"outfitBBound": true
}
}
}
@@ -0,0 +1,13 @@
{
"meta": {
"name": "Seed-san",
"authors": [
"VirtualCast, Inc."
],
"licenseUrl": "https://vrm.dev/licenses/1.0/",
"commercialUssageName": "(未设置→按VRM PL 1.0默认)",
"modificationName": "(未设置→按VRM PL 1.0默认)",
"allowedUserName": "(未设置)"
},
"loadMs": 121
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

@@ -0,0 +1 @@
{"results":[{"id":1808926,"name":"杭州","latitude":30.29365,"longitude":120.16142,"elevation":12.0,"feature_code":"PPLA","country_code":"CN","admin1_id":1784764,"admin2_id":1808925,"timezone":"Asia/Shanghai","population":11936010,"country_id":1814991,"country":"中国","admin1":"浙江","admin2":"杭州市"},{"id":6976333,"name":"杭州","latitude":30.06517,"longitude":102.19527,"elevation":2321.0,"feature_code":"PPL","country_code":"CN","admin1_id":1794299,"admin2_id":1810269,"timezone":"Asia/Shanghai","country_id":1814991,"country":"中国","admin1":"四川","admin2":"甘孜藏族自治州"}],"generationtime_ms":0.11372566}
@@ -0,0 +1 @@
{"latitude":31.247803,"longitude":121.5,"generationtime_ms":0.2837181091308594,"utc_offset_seconds":28800,"timezone":"Asia/Shanghai","timezone_abbreviation":"GMT+8","elevation":3.0,"current_units":{"time":"iso8601","interval":"seconds","temperature_2m":"°C","relative_humidity_2m":"%","precipitation":"mm","weather_code":"wmo code","wind_speed_10m":"km/h"},"current":{"time":"2026-10-20T09:45","interval":900,"temperature_2m":25.4,"relative_humidity_2m":79,"precipitation":0.00,"weather_code":3,"wind_speed_10m":5.4},"daily_units":{"time":"iso8601","sunrise":"iso8601","sunset":"iso8601","temperature_2m_max":"°C","temperature_2m_min":"°C","precipitation_sum":"mm"},"daily":{"time":["2026-10-20","2026-10-21"],"sunrise":["2026-10-20T06:00","2026-10-21T06:00"],"sunset":["2026-10-20T17:17","2026-10-21T17:16"],"temperature_2m_max":[27.6,22.0],"temperature_2m_min":[21.4,15.5],"precipitation_sum":[2.60,8.90]}}
@@ -0,0 +1 @@
{"latitude":35.7,"longitude":139.6875,"generationtime_ms":0.12254714965820312,"utc_offset_seconds":32400,"timezone":"Asia/Tokyo","timezone_abbreviation":"GMT+9","elevation":43.0,"current_units":{"time":"iso8601","interval":"seconds","temperature_2m":"°C","relative_humidity_2m":"%","precipitation":"mm","weather_code":"wmo code","wind_speed_10m":"km/h"},"current":{"time":"2026-10-20T10:45","interval":900,"temperature_2m":24.2,"relative_humidity_2m":84,"precipitation":0.00,"weather_code":2,"wind_speed_10m":7.0},"daily_units":{"time":"iso8601","sunrise":"iso8601","sunset":"iso8601","temperature_2m_max":"°C","temperature_2m_min":"°C","precipitation_sum":"mm"},"daily":{"time":["2026-10-20","2026-10-21"],"sunrise":["2026-10-20T05:51","2026-10-21T05:52"],"sunset":["2026-10-20T16:59","2026-10-21T16:58"],"temperature_2m_max":[25.2,28.0],"temperature_2m_min":[16.4,18.4],"precipitation_sum":[0.60,1.00]}}
@@ -0,0 +1 @@
{"latitude":30.263618,"longitude":120.14051,"generationtime_ms":0.2598762512207031,"utc_offset_seconds":28800,"timezone":"Asia/Shanghai","timezone_abbreviation":"GMT+8","elevation":13.0,"current_units":{"time":"iso8601","interval":"seconds","temperature_2m":"°C","relative_humidity_2m":"%","precipitation":"mm","weather_code":"wmo code","wind_speed_10m":"km/h"},"current":{"time":"2026-10-20T09:45","interval":900,"temperature_2m":24.8,"relative_humidity_2m":79,"precipitation":0.00,"weather_code":3,"wind_speed_10m":2.0},"daily_units":{"time":"iso8601","sunrise":"iso8601","sunset":"iso8601","temperature_2m_max":"°C","temperature_2m_min":"°C","precipitation_sum":"mm"},"daily":{"time":["2026-10-20","2026-10-21"],"sunrise":["2026-10-20T06:04","2026-10-21T06:05"],"sunset":["2026-10-20T17:23","2026-10-21T17:22"],"temperature_2m_max":[29.4,21.4],"temperature_2m_min":[20.7,14.8],"precipitation_sum":[2.60,4.70]}}
+666
View File
@@ -0,0 +1,666 @@
{
"name": "hel-702-3d-verify",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hel-702-3d-verify",
"dependencies": {
"@pixiv/three-vrm": "^3.4.2",
"@pixiv/three-vrm-animation": "^3.4.2",
"esbuild": "^0.25.0",
"playwright": "^1.49.0",
"three": "^0.170.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
"cpu": [
"ppc64"
],
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
"cpu": [
"loong64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
"cpu": [
"mips64el"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
"cpu": [
"ppc64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
"cpu": [
"s390x"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@pixiv/three-vrm": {
"version": "3.5.5",
"resolved": "https://registry.npmmirror.com/@pixiv/three-vrm/-/three-vrm-3.5.5.tgz",
"integrity": "sha512-RPXy7jYAXs704NIpZlosB0U2ENu21G9DrqGWdQgRe8dShaCo1ugpj+6BVPRCy91nt+MPMA96j5rbsSzEl0HlQA==",
"license": "MIT",
"dependencies": {
"@pixiv/three-vrm-core": "3.5.5",
"@pixiv/three-vrm-materials-hdr-emissive-multiplier": "3.5.5",
"@pixiv/three-vrm-materials-mtoon": "3.5.5",
"@pixiv/three-vrm-materials-v0compat": "3.5.5",
"@pixiv/three-vrm-node-constraint": "3.5.5",
"@pixiv/three-vrm-springbone": "3.5.5"
},
"peerDependencies": {
"three": ">=0.137"
}
},
"node_modules/@pixiv/three-vrm-animation": {
"version": "3.5.5",
"resolved": "https://registry.npmmirror.com/@pixiv/three-vrm-animation/-/three-vrm-animation-3.5.5.tgz",
"integrity": "sha512-Q0kEtmftxfv0iCUHQkJH2jAr1zmT9ywYXnAzhxnLPMYCsx0Kken3J3U2GJF9PdGZmLlKpoK0LFEXVdD/L7ZA4Q==",
"license": "MIT",
"dependencies": {
"@pixiv/three-vrm-core": "3.5.5",
"@pixiv/types-vrmc-vrm-1.0": "3.5.5",
"@pixiv/types-vrmc-vrm-animation-1.0": "3.5.5"
},
"peerDependencies": {
"three": ">=0.137"
}
},
"node_modules/@pixiv/three-vrm-core": {
"version": "3.5.5",
"resolved": "https://registry.npmmirror.com/@pixiv/three-vrm-core/-/three-vrm-core-3.5.5.tgz",
"integrity": "sha512-jjaXZbMVRsk2HAGVxqDLmSJyGOJqbMcJpuTHR1TY/TN1FxiKdAzSqdr/ZUcczYipNf8IL9vDT9NX2XmWj+ybRQ==",
"license": "MIT",
"dependencies": {
"@pixiv/types-vrm-0.0": "3.5.5",
"@pixiv/types-vrmc-vrm-1.0": "3.5.5"
},
"peerDependencies": {
"three": ">=0.137"
}
},
"node_modules/@pixiv/three-vrm-materials-hdr-emissive-multiplier": {
"version": "3.5.5",
"resolved": "https://registry.npmmirror.com/@pixiv/three-vrm-materials-hdr-emissive-multiplier/-/three-vrm-materials-hdr-emissive-multiplier-3.5.5.tgz",
"integrity": "sha512-XnHEqpY4ctYMucpeRcptdphOe0RrW7V5fUvojasD8Ja4MTjjrjCNFxsAvp3+HBkPZbkTM2sDCjJBJNzxN1scrA==",
"license": "MIT",
"dependencies": {
"@pixiv/types-vrmc-materials-hdr-emissive-multiplier-1.0": "3.5.5"
},
"peerDependencies": {
"three": ">=0.137"
}
},
"node_modules/@pixiv/three-vrm-materials-mtoon": {
"version": "3.5.5",
"resolved": "https://registry.npmmirror.com/@pixiv/three-vrm-materials-mtoon/-/three-vrm-materials-mtoon-3.5.5.tgz",
"integrity": "sha512-7lThHf4OKBOsVenD+R6L6HffNUlKO3Twnhr3+z77MnUsSzsOanjVKJtjSIly9QJ6hgl8YJmI6RnxCKqXcpplog==",
"license": "MIT",
"dependencies": {
"@pixiv/types-vrm-0.0": "3.5.5",
"@pixiv/types-vrmc-materials-mtoon-1.0": "3.5.5"
},
"peerDependencies": {
"three": ">=0.137"
}
},
"node_modules/@pixiv/three-vrm-materials-v0compat": {
"version": "3.5.5",
"resolved": "https://registry.npmmirror.com/@pixiv/three-vrm-materials-v0compat/-/three-vrm-materials-v0compat-3.5.5.tgz",
"integrity": "sha512-NjsTK4P4qdcr+RoL2gEUNgF2CY/aplNvyHHxI99N7D6CxDZ2H93FQ9aIuLCABj5b+4CIvldWMCcnS7YuLIr3Lw==",
"license": "MIT",
"dependencies": {
"@pixiv/types-vrm-0.0": "3.5.5",
"@pixiv/types-vrmc-materials-mtoon-1.0": "3.5.5"
},
"peerDependencies": {
"three": ">=0.137"
}
},
"node_modules/@pixiv/three-vrm-node-constraint": {
"version": "3.5.5",
"resolved": "https://registry.npmmirror.com/@pixiv/three-vrm-node-constraint/-/three-vrm-node-constraint-3.5.5.tgz",
"integrity": "sha512-+ShEXioZ5Ctf0EZ5ZViZ4n0jhV/qtJRH0Juw8nRnMNODZXDSr+B3An2lrfRoMhowCzG63GiUG4lXWV9noMXosA==",
"license": "MIT",
"dependencies": {
"@pixiv/types-vrmc-node-constraint-1.0": "3.5.5"
},
"peerDependencies": {
"three": ">=0.137"
}
},
"node_modules/@pixiv/three-vrm-springbone": {
"version": "3.5.5",
"resolved": "https://registry.npmmirror.com/@pixiv/three-vrm-springbone/-/three-vrm-springbone-3.5.5.tgz",
"integrity": "sha512-GWAWrIegGH/u1ZA+Es03Qzc2yafrQOrhtal9jzUIAgU8Wm2DYuqAABOY+du/9RpBRK8D4T8DBPvRGQqvfrQ/uA==",
"license": "MIT",
"dependencies": {
"@pixiv/types-vrm-0.0": "3.5.5",
"@pixiv/types-vrmc-springbone-1.0": "3.5.5",
"@pixiv/types-vrmc-springbone-extended-collider-1.0": "3.5.5"
},
"peerDependencies": {
"three": ">=0.137"
}
},
"node_modules/@pixiv/types-vrm-0.0": {
"version": "3.5.5",
"resolved": "https://registry.npmmirror.com/@pixiv/types-vrm-0.0/-/types-vrm-0.0-3.5.5.tgz",
"integrity": "sha512-2S/0jswMjJTwL9ky8YzDR0CIaP6ZmE+HFuyW/0h+SA+awELf+J7WGgC8lTLM1MXzokO7Z2YVljU2F0fEhlKDIQ==",
"license": "MIT"
},
"node_modules/@pixiv/types-vrmc-materials-hdr-emissive-multiplier-1.0": {
"version": "3.5.5",
"resolved": "https://registry.npmmirror.com/@pixiv/types-vrmc-materials-hdr-emissive-multiplier-1.0/-/types-vrmc-materials-hdr-emissive-multiplier-1.0-3.5.5.tgz",
"integrity": "sha512-IuHw9ehXt2bymqfdFV+f2cCXo8kvJUfh0Zuut8qmKXoEb7HVyLGIQyS4TcQHG5yY22UTWHI1Axo5tQv35lPTgQ==",
"license": "MIT"
},
"node_modules/@pixiv/types-vrmc-materials-mtoon-1.0": {
"version": "3.5.5",
"resolved": "https://registry.npmmirror.com/@pixiv/types-vrmc-materials-mtoon-1.0/-/types-vrmc-materials-mtoon-1.0-3.5.5.tgz",
"integrity": "sha512-GFVuksAJVhrWTXYmI/ucNsE611/d8N6BoZrzhUAAINndt98loyzapW2+DJu+GsUBO5yxd+vLsL0FB16XD9+TGg==",
"license": "MIT"
},
"node_modules/@pixiv/types-vrmc-node-constraint-1.0": {
"version": "3.5.5",
"resolved": "https://registry.npmmirror.com/@pixiv/types-vrmc-node-constraint-1.0/-/types-vrmc-node-constraint-1.0-3.5.5.tgz",
"integrity": "sha512-8EbJ1uzkn9yJQarCChmpG12+mc2c7PimHvQ8H6/tMh/hqp5NtJND0z2lR2KeVxw9A3M+TpF+xgImhLBxGjqwzQ==",
"license": "MIT"
},
"node_modules/@pixiv/types-vrmc-springbone-1.0": {
"version": "3.5.5",
"resolved": "https://registry.npmmirror.com/@pixiv/types-vrmc-springbone-1.0/-/types-vrmc-springbone-1.0-3.5.5.tgz",
"integrity": "sha512-gRQ5Y7PaGiMefvOrJ3hvoSPqpA67jiwyhwh8ItjsPi3yri/Hn3CeXplW3Cbx3ZPbfu+C3Q2MCRa2TqAUOjeMRA==",
"license": "MIT"
},
"node_modules/@pixiv/types-vrmc-springbone-extended-collider-1.0": {
"version": "3.5.5",
"resolved": "https://registry.npmmirror.com/@pixiv/types-vrmc-springbone-extended-collider-1.0/-/types-vrmc-springbone-extended-collider-1.0-3.5.5.tgz",
"integrity": "sha512-+6RxLaAvVguP7upjS/OT4viNPWvac1HBRQY1nmdvfYNQuCMdrMsCZqvy2af0Fcjnda4V9JbKFIpZDC0lrw1+uQ==",
"license": "MIT"
},
"node_modules/@pixiv/types-vrmc-vrm-1.0": {
"version": "3.5.5",
"resolved": "https://registry.npmmirror.com/@pixiv/types-vrmc-vrm-1.0/-/types-vrmc-vrm-1.0-3.5.5.tgz",
"integrity": "sha512-WnemHHo2375BcBdumEhvrupZzsMSzTfdWlokmV9sUX2oC+OAQqJFfBjLF8ppkOhzHpl4MAPJqEfuhBJtNPm9ww==",
"license": "MIT"
},
"node_modules/@pixiv/types-vrmc-vrm-animation-1.0": {
"version": "3.5.5",
"resolved": "https://registry.npmmirror.com/@pixiv/types-vrmc-vrm-animation-1.0/-/types-vrmc-vrm-animation-1.0-3.5.5.tgz",
"integrity": "sha512-pyow6wBLdRZbLQbtHqu8uvYG+NXJbwIiLVK6pM6UvtaYotecOv43t6qIdMLYuieV+F+bEJcdfOLFZZVolrOOcQ==",
"license": "MIT",
"dependencies": {
"@pixiv/types-vrmc-vrm-1.0": "3.5.5"
}
},
"node_modules/esbuild": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz",
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.25.12",
"@esbuild/android-arm": "0.25.12",
"@esbuild/android-arm64": "0.25.12",
"@esbuild/android-x64": "0.25.12",
"@esbuild/darwin-arm64": "0.25.12",
"@esbuild/darwin-x64": "0.25.12",
"@esbuild/freebsd-arm64": "0.25.12",
"@esbuild/freebsd-x64": "0.25.12",
"@esbuild/linux-arm": "0.25.12",
"@esbuild/linux-arm64": "0.25.12",
"@esbuild/linux-ia32": "0.25.12",
"@esbuild/linux-loong64": "0.25.12",
"@esbuild/linux-mips64el": "0.25.12",
"@esbuild/linux-ppc64": "0.25.12",
"@esbuild/linux-riscv64": "0.25.12",
"@esbuild/linux-s390x": "0.25.12",
"@esbuild/linux-x64": "0.25.12",
"@esbuild/netbsd-arm64": "0.25.12",
"@esbuild/netbsd-x64": "0.25.12",
"@esbuild/openbsd-arm64": "0.25.12",
"@esbuild/openbsd-x64": "0.25.12",
"@esbuild/openharmony-arm64": "0.25.12",
"@esbuild/sunos-x64": "0.25.12",
"@esbuild/win32-arm64": "0.25.12",
"@esbuild/win32-ia32": "0.25.12",
"@esbuild/win32-x64": "0.25.12"
}
},
"node_modules/playwright": {
"version": "1.64.1",
"resolved": "https://registry.npmmirror.com/playwright/-/playwright-1.64.1.tgz",
"integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.64.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.64.1",
"resolved": "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.64.1.tgz",
"integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/three": {
"version": "0.170.0",
"resolved": "https://registry.npmmirror.com/three/-/three-0.170.0.tgz",
"integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==",
"license": "MIT"
}
}
}
@@ -0,0 +1,17 @@
{
"name": "hel-702-3d-verify",
"private": true,
"type": "module",
"scripts": {
"build": "esbuild src/main.js --bundle --format=esm --outfile=public/bundle.js",
"serve": "npx -y serve public -l 8931",
"capture": "node capture/capture.mjs"
},
"dependencies": {
"@pixiv/three-vrm": "^3.4.2",
"@pixiv/three-vrm-animation": "^3.4.2",
"esbuild": "^0.25.0",
"playwright": "^1.49.0",
"three": "^0.170.0"
}
}
@@ -0,0 +1,31 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>HEL-702 · 独立3D角色验证</title>
<style>
html, body { margin: 0; height: 100%; overflow: hidden; background: #1a1d24; }
#app { position: fixed; inset: 0; }
#hud {
position: fixed; left: 12px; top: 12px; z-index: 10;
font: 12px/1.55 ui-monospace, Menlo, Consolas, monospace;
color: #e8e4da; background: rgba(18, 20, 28, 0.82);
padding: 10px 14px; border-radius: 8px; max-width: 520px;
border: 1px solid rgba(255,255,255,0.08); white-space: pre-wrap;
}
#caption {
position: fixed; left: 50%; bottom: 26px; transform: translateX(-50%); z-index: 10;
font: 15px/1.4 system-ui, sans-serif; color: #fff;
background: rgba(0,0,0,0.55); padding: 8px 18px; border-radius: 999px;
opacity: 0; transition: opacity .35s; pointer-events: none;
}
</style>
</head>
<body>
<div id="app"></div>
<div id="hud">加载中…</div>
<div id="caption"></div>
<script type="module" src="bundle.js"></script>
</body>
</html>
@@ -0,0 +1,68 @@
import fs from 'node:fs';
function parseGLB(buf) {
if (buf.readUInt32LE(0) !== 0x46546c67) throw new Error('not GLB');
const jsonLen = buf.readUInt32LE(12);
return JSON.parse(buf.slice(20, 20 + jsonLen).toString('utf8'));
}
const WANT_BONES = new Set(['hips','spine','chest','upperChest','neck','head',
'leftUpperLeg','leftLowerLeg','leftFoot','rightUpperLeg','rightLowerLeg','rightFoot',
'leftUpperArm','leftLowerArm','leftHand','rightUpperArm','rightLowerArm','rightHand']);
for (const file of process.argv.slice(2)) {
const buf = fs.readFileSync(file);
const g = parseGLB(buf);
const vrm0 = g.extensions?.VRM;
const vrm1 = g.extensions?.VRMC_vrm;
const out = { file, sizeBytes: buf.byteLength, gltfVersion: g.asset?.version, generator: g.asset?.generator };
if (vrm1) {
const m = vrm1.meta;
out.standard = 'VRM1.0';
out.meta = {
name: m?.name, authors: m?.authors, licenseUrl: m?.licenseUrl,
commercialUssageName: m?.commercialUssageName, modificationName: m?.modificationName,
allowPoliticalReligious: m?.allowPoliticalReligiousName, allowAntisocialHate: m?.allowAntisocialOrHateUsageName,
creditNotation: m?.creditNotationName, redistribution: m?.redistributionName,
modification: m?.modificationName, allowedUserName: m?.allowedUserName,
references: m?.references, contactInformation: m?.contactInformation,
};
const hb = vrm1.humanoid?.humanBones ?? {};
const have = new Set(Array.isArray(hb) ? hb.map((b) => b.bone) : Object.keys(hb));
out.humanoidMissing = [...WANT_BONES].filter((b) => !have.has(b));
out.humanoidCount = have.size;
const ex = vrm1.expressions ?? {};
out.expressions = (Array.isArray(ex) ? ex : Object.values(ex)).map((e) => e.name);
} else if (vrm0) {
out.standard = 'VRM0.x';
const m = vrm0.meta;
out.meta = {
title: m?.title, author: m?.author,
allowedUserName: m?.allowedUserName, commercialUssageName: m?.commercialUssageName,
sexualUssageName: m?.sexualUssageName, violentUssageName: m?.violentUssageName,
licenseName: m?.licenseName, licenseUrl: m?.licenseUrl, otherLicenseUrl: m?.otherLicenseUrl,
};
out.humanoidMissing = [];
out.expressions = (vrm0.blendShapeMaster?.blendShapeGroups ?? []).map((b) => b.name);
}
const nodeNames = (g.nodes ?? []).map((n) => n.name);
out.nodes = g.nodes?.length ?? 0;
out.meshes = (g.meshes ?? []).map((m) => ({
name: m.name,
primitives: m.primitives.length,
vertices: m.primitives[0]?.attributes?.POSITION ? 'y' : '?',
skinned: !!m.primitives[0]?.attributes?.JOINTS_0,
targets: (m.primitives[0]?.targets ?? []).length,
}));
out.materials = (g.materials ?? []).map((m) => ({
name: m.name,
type: m.extensions?.VRMC_materials_mtoon ? 'MToon' : (m.extensions?.KHR_materials_unlit ? 'unlit' : 'pbr'),
}));
out.textures = g.images?.length ?? 0;
out.imageFormats = [...new Set((g.images ?? []).map((i) => i.mimeType ?? i.uri?.split('.').pop() ?? '?'))];
const joints = g.skins?.[0]?.joints?.length ?? 0;
out.skinJoints = joints;
console.log(JSON.stringify(out, null, 2));
}
+553
View File
@@ -0,0 +1,553 @@
import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { VRMLoaderPlugin, VRMUtils } from '@pixiv/three-vrm';
import { VRMAnimationLoaderPlugin, createVRMAnimationClip } from '@pixiv/three-vrm-animation';
/* ============ HEL-702 独立3D角色验证页 ============
* 环境状态(time/weather) 与 角色状态(outfit/action/gaze) 完全分离。
* 天气/时间切换不触碰角色骨架 —— assertPosePreserved() 可程序化验证。
* ================================================== */
const params = new URLSearchParams(location.search);
const MODEL = params.get('model') || 'twist';
const modelFile = MODEL === 'seed' ? 'assets/Seed-san.vrm' : 'assets/VRM1_Constraint_Twist_Sample.vrm';
const app = {
ready: false,
state: { outfit: 'A', action: 'stand', time: 'day', weather: 'sunny', gaze: 'idle' },
stats: {},
notes: [],
};
/* ---------- renderer / scene ---------- */
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
document.getElementById('app').appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(30, innerWidth / innerHeight, 0.1, 50);
const CAMS = {
main: { pos: [1.35, 1.35, 2.6], look: [0, 0.9, 0] },
front: { pos: [0, 1.15, 2.3], look: [0, 0.95, 0] },
wide: { pos: [2.3, 1.7, 3.4], look: [0.1, 0.8, 0] },
closeup: { pos: [0.75, 1.15, 1.15], look: [0.05, 1.05, 0.1] },
};
function setCam(name) {
const c = CAMS[name] || CAMS.main;
camera.position.set(...c.pos);
camera.lookAt(...c.look);
}
setCam(params.get('cam') || 'main');
/* ---------- room ---------- */
const room = new THREE.Group();
scene.add(room);
const matWall = new THREE.MeshStandardMaterial({ color: 0xcfc8bd, roughness: 0.95 });
const matWall2 = new THREE.MeshStandardMaterial({ color: 0xb9b2a6, roughness: 0.95 });
const matFloor = new THREE.MeshStandardMaterial({ color: 0x8a6a4e, roughness: 0.8 });
const matWood = new THREE.MeshStandardMaterial({ color: 0xa07850, roughness: 0.7 });
const floor = new THREE.Mesh(new THREE.BoxGeometry(4.6, 0.08, 4.6), matFloor);
floor.position.y = -0.04;
floor.receiveShadow = true;
room.add(floor);
const wallBack = new THREE.Mesh(new THREE.BoxGeometry(4.6, 2.75, 0.08), matWall);
wallBack.position.set(0, 1.375, -2.3);
wallBack.receiveShadow = true;
room.add(wallBack);
const wallLeft = new THREE.Mesh(new THREE.BoxGeometry(0.08, 2.75, 4.6), matWall2);
wallLeft.position.set(-2.3, 1.375, 0);
room.add(wallLeft);
/* window opening in back wall: 4 boxes around a hole (x: cx±x/2) */
const WIN = { x: 0.62, y: 1.0, cx: 0.55, cy: 1.45 };
function wallPiece(w, h, cx, cy) {
const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, 0.08), matWall);
m.position.set(cx, cy, -2.3);
m.receiveShadow = true;
room.add(m);
}
wallPiece(4.6, 2.75 - (WIN.cy + WIN.y / 2), 0, WIN.cy + WIN.y / 2 + (2.75 - (WIN.cy + WIN.y / 2)) / 2); // top
wallPiece(4.6, WIN.cy - WIN.y / 2, 0, (WIN.cy - WIN.y / 2) / 2); // bottom
wallPiece((WIN.cx - WIN.x / 2) - (-2.3), WIN.y, (-2.3 + (WIN.cx - WIN.x / 2)) / 2, WIN.cy); // left
wallPiece(2.3 - (WIN.cx + WIN.x / 2), WIN.y, (2.3 + (WIN.cx + WIN.x / 2)) / 2, WIN.cy); // right
/* window frame + glass */
const frameMat = new THREE.MeshStandardMaterial({ color: 0xf0ece4, roughness: 0.5 });
const fr1 = new THREE.Mesh(new THREE.BoxGeometry(WIN.x + 0.08, 0.05, 0.1), frameMat);
fr1.position.set(WIN.cx, WIN.cy + WIN.y / 2, -2.28);
const fr2 = fr1.clone(); fr2.position.y = WIN.cy - WIN.y / 2;
const fr3 = new THREE.Mesh(new THREE.BoxGeometry(0.05, WIN.y + 0.08, 0.1), frameMat);
fr3.position.set(WIN.cx - WIN.x / 2, WIN.cy, -2.28);
const fr4 = fr3.clone(); fr4.position.x = WIN.cx + WIN.x / 2;
const fr5 = new THREE.Mesh(new THREE.BoxGeometry(0.03, WIN.y, 0.09), frameMat);
fr5.position.set(WIN.cx, WIN.cy, -2.28);
room.add(fr1, fr2, fr3, fr4, fr5);
const skyMat = new THREE.MeshBasicMaterial({ color: 0xbcd7f0 });
const sky = new THREE.Mesh(new THREE.PlaneGeometry(7, 5), skyMat);
sky.position.set(WIN.cx, WIN.cy, -2.65);
room.add(sky);
/* distant city silhouette (dark boxes outside window) */
const cityMat = new THREE.MeshBasicMaterial({ color: 0x6f7f96 });
const city = new THREE.Group();
for (let i = 0; i < 9; i++) {
const h = 0.5 + Math.abs(Math.sin(i * 2.7)) * 1.1;
const b = new THREE.Mesh(new THREE.BoxGeometry(0.42, h, 0.2), cityMat);
b.position.set(WIN.cx - 1.6 + i * 0.42, h / 2 - 0.4, -2.5);
city.add(b);
}
room.add(city);
/* rug */
const rug = new THREE.Mesh(new THREE.CircleGeometry(0.85, 40), new THREE.MeshStandardMaterial({ color: 0x9c4f3f, roughness: 1 }));
rug.rotation.x = -Math.PI / 2;
rug.position.set(-0.55, 0.005, 0.35);
rug.receiveShadow = true;
room.add(rug);
/* desk + chair + lamp (椅面高 0.46m,坐姿按它计算) */
const chair = new THREE.Group();
const seatY = 0.46;
const seat = new THREE.Mesh(new THREE.BoxGeometry(0.44, 0.05, 0.44), matWood);
seat.position.y = seatY - 0.025;
seat.castShadow = true;
const backrest = new THREE.Mesh(new THREE.BoxGeometry(0.44, 0.5, 0.05), matWood);
backrest.position.set(0, seatY + 0.25, -0.2);
backrest.castShadow = true;
chair.add(seat, backrest);
for (const [x, z] of [[-0.18, -0.18], [0.18, -0.18], [-0.18, 0.18], [0.18, 0.18]]) {
const leg = new THREE.Mesh(new THREE.BoxGeometry(0.05, seatY - 0.05, 0.05), matWood);
leg.position.set(x, (seatY - 0.05) / 2, z);
chair.add(leg);
}
chair.position.set(0.62, 0, 0.1);
chair.rotation.y = -0.5;
room.add(chair);
const desk = new THREE.Group();
const top = new THREE.Mesh(new THREE.BoxGeometry(1.1, 0.05, 0.55), matWood);
top.position.y = 0.74;
top.castShadow = true;
desk.add(top);
for (const [x, z] of [[-0.5, -0.22], [0.5, -0.22], [-0.5, 0.22], [0.5, 0.22]]) {
const leg = new THREE.Mesh(new THREE.BoxGeometry(0.05, 0.74, 0.05), matWood);
leg.position.set(x, 0.37, z);
desk.add(leg);
}
const mug = new THREE.Mesh(new THREE.CylinderGeometry(0.045, 0.045, 0.1, 16), new THREE.MeshStandardMaterial({ color: 0xd8cfc4, roughness: 0.4 }));
mug.position.set(0.25, 0.815, 0.06);
desk.add(mug);
desk.position.set(-0.85, 0, -0.75);
desk.rotation.y = 0.35;
room.add(desk);
const lampG = new THREE.Group();
const lampStand = new THREE.Mesh(new THREE.CylinderGeometry(0.02, 0.03, 1.25, 10), new THREE.MeshStandardMaterial({ color: 0x333333, metalness: 0.7, roughness: 0.4 }));
lampStand.position.y = 0.625;
const lampShade = new THREE.Mesh(new THREE.ConeGeometry(0.14, 0.18, 16, 1, true), new THREE.MeshStandardMaterial({ color: 0xf5e6c8, roughness: 0.6, side: THREE.DoubleSide }));
lampShade.position.y = 1.3;
lampG.add(lampStand, lampShade);
lampG.position.set(1.55, 0, -1.9);
room.add(lampG);
/* book prop (read 动作时挂到右手) */
const bookG = new THREE.Group();
const bookCover = new THREE.Mesh(new THREE.BoxGeometry(0.16, 0.015, 0.22), new THREE.MeshStandardMaterial({ color: 0x8a3033, roughness: 0.6 }));
const bookPage = new THREE.Mesh(new THREE.BoxGeometry(0.145, 0.012, 0.205), new THREE.MeshStandardMaterial({ color: 0xf2ecd9, roughness: 0.9 }));
bookPage.position.y = 0.008;
bookG.add(bookCover, bookPage);
bookG.visible = false;
/* ---------- lights ---------- */
const hemi = new THREE.HemisphereLight(0xdfe8f5, 0x5a4a3a, 0.55);
scene.add(hemi);
const sun = new THREE.DirectionalLight(0xfff2dd, 2.0);
sun.position.set(3, 4, 1.5);
sun.castShadow = true;
sun.shadow.mapSize.set(1024, 1024);
sun.shadow.camera.left = -3; sun.shadow.camera.right = 3;
sun.shadow.camera.top = 3; sun.shadow.camera.bottom = -3;
scene.add(sun);
const lampLight = new THREE.PointLight(0xffc98a, 0, 4, 2);
lampLight.position.set(1.55, 1.22, -1.9);
scene.add(lampLight);
/* rain (outside window) */
const RAIN_N = 500;
const rainGeo = new THREE.BufferGeometry();
const rainPos = new Float32Array(RAIN_N * 3);
for (let i = 0; i < RAIN_N; i++) {
rainPos[i * 3] = WIN.cx - 1.8 + Math.random() * 3.6;
rainPos[i * 3 + 1] = Math.random() * 4;
rainPos[i * 3 + 2] = -2.5 - Math.random() * 0.6;
}
rainGeo.setAttribute('position', new THREE.BufferAttribute(rainPos, 3));
const rain = new THREE.Points(rainGeo, new THREE.PointsMaterial({ color: 0xaec6e8, size: 0.022, transparent: true, opacity: 0.85 }));
rain.visible = false;
scene.add(rain);
/* ---------- VRM character ---------- */
let vrm = null;
let mixer = null;
let vrmaAction = null;
let vrmaClipLen = 0;
const rawBone = (n) => vrm?.humanoid.getRawBoneNode(n) ?? null;
const normBone = (n) => vrm?.humanoid.getNormalizedBoneNode(n) ?? null;
/* outfit B: 自制占位服装(连帽衫+长裤,硬segment绑定原始骨骼) */
const outfitB = new THREE.Group();
const matPants = new THREE.MeshStandardMaterial({ color: 0x46618f, roughness: 0.85 });
const matHood = new THREE.MeshStandardMaterial({ color: 0x9aa3ad, roughness: 0.9 });
function seg(mesh, bone, pos) { mesh.position.copy(pos); mesh.castShadow = true; outfitB.add(mesh); mesh.userData.bone = bone; return mesh; }
const B = (g, m) => new THREE.Mesh(g, m);
seg(B(new THREE.BoxGeometry(0.34, 0.34, 0.2), matHood), 'chest', new THREE.Vector3(0, 0.02, 0)); // 衣身
seg(B(new THREE.BoxGeometry(0.14, 0.12, 0.14), matHood), 'neck', new THREE.Vector3(0, 0.08, -0.03)); // 领口
seg(B(new THREE.CylinderGeometry(0.055, 0.05, 0.26, 8), matHood), 'leftUpperArm', new THREE.Vector3(0, -0.1, 0)); // 袖L上
seg(B(new THREE.CylinderGeometry(0.05, 0.045, 0.24, 8), matHood), 'leftLowerArm', new THREE.Vector3(0, -0.1, 0)); // 袖L下
seg(B(new THREE.CylinderGeometry(0.055, 0.05, 0.26, 8), matHood), 'rightUpperArm', new THREE.Vector3(0, -0.1, 0)); // 袖R上
seg(B(new THREE.CylinderGeometry(0.05, 0.045, 0.24, 8), matHood), 'rightLowerArm', new THREE.Vector3(0, -0.1, 0)); // 袖R下
for (const side of ['left', 'right']) {
seg(B(new THREE.CylinderGeometry(0.085, 0.075, 0.3, 10), matPants), side + 'UpperLeg', new THREE.Vector3(0, -0.15, 0)); // 裤大腿
seg(B(new THREE.CylinderGeometry(0.07, 0.06, 0.3, 10), matPants), side + 'LowerLeg', new THREE.Vector3(0, -0.14, 0)); // 裤小腿
}
outfitB.visible = false;
scene.add(outfitB);
/* ---------- 姿势系统(normalized 骨架,目标角度平滑过渡) ---------- */
const POSES = {
stand: {
hips: { pos: null },
leftShoulder: { z: 0.04 }, rightShoulder: { z: -0.04 },
leftUpperArm: { z: -1.22, x: 0.1 }, rightUpperArm: { z: 1.22, x: 0.1 },
leftLowerArm: { z: -0.18 }, rightLowerArm: { z: 0.18 },
spine: { x: 0.02 }, neck: { x: 0.03 },
},
sit: {
leftUpperLeg: { x: -1.45 }, rightUpperLeg: { x: -1.45 },
leftLowerLeg: { x: 1.42 }, rightLowerLeg: { x: 1.42 },
leftFoot: { x: -0.02 }, rightFoot: { x: -0.02 },
spine: { x: 0.09 }, neck: { x: -0.04 },
},
read: {
leftUpperLeg: { x: -1.45 }, rightUpperLeg: { x: -1.45 },
leftLowerLeg: { x: 1.42 }, rightLowerLeg: { x: 1.42 },
leftFoot: { x: -0.02 }, rightFoot: { x: -0.02 },
spine: { x: 0.14 }, neck: { x: 0.22 },
leftShoulder: { x: -0.5 }, rightShoulder: { x: -0.5 },
leftUpperArm: { z: -0.25, y: -1.05 }, rightUpperArm: { z: 0.25, y: 1.05 },
leftLowerArm: { y: -1.1, z: -0.3 }, rightLowerArm: { y: 1.1, z: 0.3 },
},
};
const poseTargets = {}; // bone -> {quat, posOffset}
let sitOffsetY = 0;
let baseHipY = 0;
function computeTargets(name) {
const p = POSES[name] || POSES.stand;
for (const k of Object.keys(poseTargets)) delete poseTargets[k];
for (const [bone, rot] of Object.entries(p)) {
const n = normBone(bone);
if (!n) continue;
const q = new THREE.Quaternion();
if (rot.x) q.multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), rot.x));
if (rot.y) q.multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), rot.y));
if (rot.z) q.multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 0, 1), rot.z));
poseTargets[bone] = { quat: q };
}
if (name === 'sit' || name === 'read') {
poseTargets.hips = { quat: new THREE.Quaternion(), posY: hipsRestY + (seatY + 0.055 - baseHipY) };
} else {
poseTargets.hips = { quat: new THREE.Quaternion(), posY: hipsRestY };
}
}
/* gaze */
const lookTarget = new THREE.Object3D();
scene.add(lookTarget);
let gazeMode = 'idle';
let gazeWanderT = 0;
/* ---------- HUD ---------- */
const hud = document.getElementById('hud');
function renderHUD() {
const s = app.state;
const i = renderer.info;
hud.innerHTML = `
<b>HEL-702 · 独立角色验证</b> · ${MODEL === 'seed' ? 'Seed-san' : 'VRM1_Constraint_Twist_Sample'}<br>
状态: 衣=${s.outfit} 动作=${s.action} 时间=${s.time} 天气=${s.weather} 视线=${s.gaze}<br>
draw=${i.render.calls} tri=${i.render.triangles.toLocaleString()} tex=${i.memory.textures} geo=${i.memory.geometries} fps=${app.stats.fps ?? '-'}<br>
<span style="color:#888">${app.notes.length ? app.notes[app.notes.length - 1] : ''}</span>`;
}
/* ---------- decoupling assertion ---------- */
let poseSnapshot = null;
function snapshotPose() {
const names = ['hips', 'head', 'leftHand', 'rightHand', 'leftUpperLeg'];
const out = {};
for (const n of names) {
const b = normBone(n);
if (b) out[n] = b.quaternion.clone();
}
return out;
}
app.assertPosePreserved = () => {
if (!poseSnapshot) return { pass: null, reason: 'no snapshot' };
let maxDelta = 0;
for (const [n, q] of Object.entries(poseSnapshot)) {
const b = normBone(n);
if (b) maxDelta = Math.max(maxDelta, b.quaternion.angleTo(q));
}
const pass = maxDelta < 1e-4;
app.notes.push(`环境切换后姿态偏移 = ${maxDelta.toExponential(2)} rad → ${pass ? 'PASS 未重置' : 'FAIL 被重置'}`);
return { pass, maxDelta };
};
/* ---------- state apply ---------- */
function applyState(next) {
Object.assign(app.state, next);
const s = app.state;
if (!vrm) return;
/* 环境: 完全不触碰 vrm 与姿势 */
if (s.time === 'night') {
sun.intensity = 0.04; hemi.intensity = 0.1;
lampLight.intensity = 14; skyMat.color.set(0x0a1428); cityMat.color.set(0x1a2230);
renderer.toneMappingExposure = 1.0;
} else {
sun.intensity = 2.0; hemi.intensity = 0.55;
lampLight.intensity = 0; skyMat.color.set(0xbcd7f0); cityMat.color.set(0x6f7f96);
renderer.toneMappingExposure = 1.0;
}
rain.visible = s.weather === 'rain';
if (s.weather === 'rain') {
if (s.time === 'night') { skyMat.color.set(0x0a1428); }
else skyMat.color.set(0x93a8bd);
}
/* 角色 */
const garments = { Tops: false, Bottoms: false, Shoes: false };
if (s.outfit === 'B') { garments.Tops = true; garments.Bottoms = true; }
vrm.scene.traverse((o) => {
if (o.isMesh) {
const mats = Array.isArray(o.material) ? o.material : [o.material];
for (const m of mats) {
if (/Tops/.test(m.name)) m.visible = !garments.Tops;
if (/Bottoms/.test(m.name)) m.visible = !garments.Bottoms;
}
}
});
outfitB.visible = s.outfit === 'B';
if (s.action === 'vrma') {
if (vrmaAction) { vrmaAction.play(); computeTargets('stand'); }
} else {
if (vrmaAction) vrmaAction.stop();
computeTargets(s.action);
}
rootTarget = ROOT_T[s.action] || ROOT_T.stand;
bookG.visible = s.action === 'read';
gazeMode = s.gaze;
if (vrm.lookAt) vrm.lookAt.target = s.gaze === 'camera' ? camera : lookTarget;
if (vrm.expressionManager) vrm.expressionManager.setValue('happy', s.gaze === 'camera' ? 0.9 : 0);
renderHUD();
}
app.setState = (next) => { if (poseSnapshotForNext(next)) poseSnapshot = snapshotPose(); applyState(next); };
function poseSnapshotForNext(next) {
return ('time' in next) || ('weather' in next);
}
app.snapshotPose = () => { poseSnapshot = snapshotPose(); app.notes.push('已记录当前姿态快照'); renderHUD(); };
/* ---------- load ---------- */
const t0 = performance.now();
const loader = new GLTFLoader();
loader.register((p) => new VRMLoaderPlugin(p));
loader.register((p) => new VRMAnimationLoaderPlugin(p));
const [gltf, gltfA, manifest] = await Promise.all([
loader.loadAsync(modelFile),
loader.loadAsync('assets/test.vrma'),
fetch('assets/manifest.json').then((r) => r.json()),
]);
const loadMs = Math.round(performance.now() - t0);
vrm = gltf.userData.vrm;
if (!vrm) {
console.error('[load] userData keys:', Object.keys(gltf.userData), 'extensions:', gltf.parser?.json?.extensionsUsed, gltf.parser?.json?.extensions);
throw new Error('VRM plugin did not produce vrm from ' + modelFile);
}
VRMUtils.removeUnnecessaryVertices(gltf.scene);
VRMUtils.combineSkeletons?.(gltf.scene);
vrm.scene.traverse((o) => { if (o.isMesh) { o.castShadow = true; o.frustumCulled = false; } });
/* VRM1 模型面朝 +Z;镜头在 +Z 侧,不旋转 */
scene.add(vrm.scene);
if (vrm.lookAt) vrm.lookAt.target = lookTarget;
/* meta (embedded license info, read at runtime) */
const m = vrm.meta;
app.stats.modelMeta = {
name: m.name, authors: m.authors, licenseUrl: m.licenseUrl,
commercialUssageName: m.commercialUssageName ?? '(未设置→按VRM PL 1.0默认)',
modificationName: m.modificationName ?? '(未设置→按VRM PL 1.0默认)',
allowedUserName: m.allowedUserName ?? '(未设置)',
};
const entry = manifest.assets.find((a) => (MODEL === 'seed' ? a.file.includes('Seed') : a.file.includes('Twist')));
app.stats.asset = entry;
app.stats.loadMs = loadMs;
/* VRMA animation */
const vrmA = gltfA.userData.vrmAnimation;
if (vrmA) {
const clip = createVRMAnimationClip(vrmA, vrm);
vrmaClipLen = clip.duration;
mixer = new THREE.AnimationMixer(vrm.scene);
vrmaAction = mixer.clipAction(clip);
app.stats.vrma = { duration: clip.duration, tracks: clip.tracks.length, name: entry ? 'test.vrma' : 'test.vrma' };
}
/* pose init */
const hips = normBone('hips');
const hipsRestY = hips ? hips.position.y : 0;
baseHipY = hips ? hips.getWorldPosition(new THREE.Vector3()).y : 0.86;
sitOffsetY = seatY + 0.055 - baseHipY;
computeTargets('stand');
/* 书本:每帧放在两手之间并对齐角色朝向(阅读姿态可证据化:bookToHand 距离) */
scene.add(bookG);
const _rH = new THREE.Vector3(); const _lH = new THREE.Vector3(); const _mid = new THREE.Vector3();
function placeBook() {
const r = rawBone('rightHand'); const l = rawBone('leftHand');
if (!r || !l) return;
r.getWorldPosition(_rH); l.getWorldPosition(_lH);
_mid.copy(_rH).add(_lH).multiplyScalar(0.5);
bookG.position.copy(_mid).y += 0.02;
bookG.rotation.set(-1.35, vrm.scene.rotation.y, 0.1, 'YXZ');
}
/* runtime bone-binding of outfitB segments (snapshot: add() reparents) */
for (const o of [...outfitB.children]) {
if (o.userData.bone) {
const b = rawBone(o.userData.bone);
if (b) b.add(o);
}
}
/* 根节点运动目标(站立在地毯;坐下/阅读在椅子上) */
const ROOT_T = {
stand: { pos: new THREE.Vector3(-0.3, 0, 0.35), yaw: 0.45 },
sit: { pos: new THREE.Vector3(0.62, 0, 0.1), yaw: -0.3 },
read: { pos: new THREE.Vector3(0.62, 0, 0.1), yaw: -0.3 },
vrma: { pos: new THREE.Vector3(0, 0, 0), yaw: 0 },
};
let rootTarget = ROOT_T.stand;
app.probe = () => {
const wp = (n) => { const b = normBone(n); return b ? b.getWorldPosition(new THREE.Vector3()) : null; };
const head = wp('head'); const hipsP = wp('hips');
const lFoot = wp('leftFoot'); const rFoot = wp('rightFoot');
const lKnee = wp('leftLowerLeg'); const rHandW = rawBone('rightHand') ? rawBone('rightHand').getWorldPosition(new THREE.Vector3()) : null;
const lHandW = rawBone('leftHand') ? rawBone('leftHand').getWorldPosition(new THREE.Vector3()) : null;
const bookW = bookG.visible ? bookG.getWorldPosition(new THREE.Vector3()) : null;
const faceDir = new THREE.Vector3();
const headN = normBone('head');
if (headN) headN.getWorldDirection(faceDir);
const toCam = head ? camera.position.clone().sub(head).normalize() : new THREE.Vector3();
return {
headY: +head?.y?.toFixed(3), hipsY: +hipsP?.y?.toFixed(3),
footMinY: +Math.min(lFoot?.y ?? 9, rFoot?.y ?? 9).toFixed(3),
kneeFwd: lKnee && hipsP ? +(lKnee.z - hipsP.z).toFixed(3) : null,
faceAngleToCamDeg: head ? +THREE.MathUtils.radToDeg(faceDir.angleTo(toCam)).toFixed(1) : null,
bookToRHand: bookW && rHandW ? +bookW.distanceTo(rHandW).toFixed(3) : null,
bookToLHand: bookW && lHandW ? +bookW.distanceTo(lHandW).toFixed(3) : null,
handDist: rHandW && lHandW ? +rHandW.distanceTo(lHandW).toFixed(3) : null,
rHandPos: rHandW ? rHandW.toArray().map((v) => +v.toFixed(2)) : null,
lHandPos: lHandW ? lHandW.toArray().map((v) => +v.toFixed(2)) : null,
headPos: head ? head.toArray().map((v) => +v.toFixed(2)) : null,
bbox: (() => { const b = new THREE.Box3().setFromObject(vrm.scene); const sz = b.getSize(new THREE.Vector3()); return sz.toArray().map((v) => +v.toFixed(3)); })(),
eyeYaw: (() => { const la = vrm.lookAt; return la ? +THREE.MathUtils.radToDeg(la.yaw).toFixed(1) : null; })(),
happyWeight: vrm.expressionManager ? +vrm.expressionManager.getValue('happy').toFixed(2) : null,
rootPos: vrm.scene.position.toArray().map((v) => +v.toFixed(2)),
rootYaw: +vrm.scene.rotation.y.toFixed(2),
seatY, outfitBBound: outfitB.children.length === 0,
};
};
app.ready = true;
app.notes.push(`模型加载 ${loadMs}ms · ${entry ? (entry.sizeMB + 'MB') : ''} · 授权: ${m.licenseUrl}`);
applyState({});
/* ---------- loop ---------- */
const clock = new THREE.Clock();
let fpsAcc = 0, fpsN = 0, fpsT = 0;
function loop() {
requestAnimationFrame(loop);
const dt = Math.min(clock.getDelta(), 0.05);
const t = clock.elapsedTime;
/* root motion: 站位↔椅子 平滑移动 */
if (vrm) {
const k = 1 - Math.exp(-dt * 3.2);
vrm.scene.position.lerp(rootTarget.pos, k);
let dy = rootTarget.yaw - vrm.scene.rotation.y;
dy = Math.atan2(Math.sin(dy), Math.cos(dy));
vrm.scene.rotation.y += dy * k;
}
/* pose easing toward targets (动作自然过渡) */
if (vrm && app.state.action !== 'vrma') {
const k = 1 - Math.exp(-dt * 5.5);
for (const [bone, tgt] of Object.entries(poseTargets)) {
const n = normBone(bone);
if (!n) continue;
n.quaternion.slerp(tgt.quat, k);
if (tgt.posY !== undefined && bone === 'hips') n.position.y = THREE.MathUtils.lerp(n.position.y, tgt.posY, k);
}
/* breathing + micro sway on stand */
const chest = normBone('chest');
if (chest && (app.state.action === 'stand')) {
chest.rotation.x = 0.02 * Math.sin(t * 1.7);
}
}
if (app.state.action === 'read') placeBook();
if (mixer && app.state.action === 'vrma') mixer.update(dt);
if (vrm) vrm.update(dt);
/* gaze wander */
if (gazeMode === 'idle') {
gazeWanderT += dt;
lookTarget.position.set(0.35 * Math.sin(t * 0.31), 1.15 + 0.12 * Math.sin(t * 0.21), 1.3);
} else {
lookTarget.position.copy(camera.position);
}
/* rain fall */
if (rain.visible) {
const pos = rain.geometry.attributes.position;
for (let i = 0; i < RAIN_N; i++) {
let y = pos.getY(i) - dt * (2.6 + (i % 7) * 0.2);
if (y < 0) y = 4;
pos.setY(i, y);
}
pos.needsUpdate = true;
}
renderer.render(scene, camera);
fpsAcc += dt; fpsN++; fpsT += dt;
if (fpsT > 0.5) {
app.stats.fps = Math.round(fpsN / fpsAcc);
fpsAcc = 0; fpsN = 0; fpsT = 0;
app.stats.info = {
calls: renderer.info.render.calls,
triangles: renderer.info.render.triangles,
textures: renderer.info.memory.textures,
geometries: renderer.info.memory.geometries,
};
if (performance) {
if (performance.memory) app.stats.heapMB = Math.round(performance.memory.usedJSHeapSize / 1e6);
}
renderHUD();
}
}
loop();
window.__app = app;
window.__stats = app.stats;