init
This commit is contained in:
84
.drone.yml
Normal file
84
.drone.yml
Normal file
@ -0,0 +1,84 @@
|
||||
kind: pipeline # 定义一个管道
|
||||
type: docker # 当前管道的类型
|
||||
name: test # 当前管道的名称
|
||||
|
||||
steps:
|
||||
|
||||
# 第一步:构建项目
|
||||
- name: 构建项目
|
||||
image: node:18-alpine
|
||||
commands:
|
||||
- rm -rf node_modules
|
||||
- npm ci
|
||||
- npm run build
|
||||
|
||||
# 第二步:上传静态资源到腾讯云COS (使用另一个插件)
|
||||
- name: 静态资源上传到cos
|
||||
image: ccr.ccs.tencentyun.com/xiaoqidun/gocos:latest
|
||||
settings:
|
||||
secret_id:
|
||||
from_secret: cos_secret_id
|
||||
secret_key:
|
||||
from_secret: cos_secret_key
|
||||
bucket_url: https://files-1302416092.cos.ap-shanghai.myqcloud.com
|
||||
source_path: dist
|
||||
target_path: /studio
|
||||
strip_prefix: dist
|
||||
|
||||
# 第三步:部署到服务器
|
||||
- name: 清除服务器缓存
|
||||
image: appleboy/drone-ssh
|
||||
settings:
|
||||
host:
|
||||
from_secret: server_host
|
||||
username:
|
||||
from_secret: server_username
|
||||
password:
|
||||
from_secret: server_password
|
||||
# 或者使用SSH密钥
|
||||
# key:
|
||||
# from_secret: server_ssh_key
|
||||
port: 22
|
||||
script:
|
||||
- rm -rf /www/wwwroot/doc.zguiy.com/*
|
||||
- mkdir -p /www/wwwroot/doc.zguiy.com/
|
||||
- chmod 755 /www/wwwroot/doc.zguiy.com/
|
||||
when:
|
||||
branch:
|
||||
- main
|
||||
- master
|
||||
- dev
|
||||
|
||||
# 第四步:上传构建文件
|
||||
- name: 上传构建文件
|
||||
image: appleboy/drone-scp
|
||||
settings:
|
||||
host:
|
||||
from_secret: server_host
|
||||
username:
|
||||
from_secret: server_username
|
||||
password:
|
||||
from_secret: server_password
|
||||
# 或者使用SSH密钥
|
||||
# key:
|
||||
# from_secret: server_ssh_key
|
||||
port: 22
|
||||
source: dist/*
|
||||
target: /www/wwwroot/doc.zguiy.com/
|
||||
strip_components: 1
|
||||
when:
|
||||
branch:
|
||||
- main
|
||||
- master
|
||||
- dev
|
||||
|
||||
|
||||
# 触发条件
|
||||
trigger:
|
||||
branch:
|
||||
- main
|
||||
- master
|
||||
- dev
|
||||
event:
|
||||
- push
|
||||
- pull_request
|
||||
1
.env.development
Normal file
1
.env.development
Normal file
@ -0,0 +1 @@
|
||||
VITE_PUBLIC = /
|
||||
1
.env.production
Normal file
1
.env.production
Normal file
@ -0,0 +1 @@
|
||||
VITE_PUBLIC = https://cdn.files.zguiy.com/studio/
|
||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
/node_modules/
|
||||
/public/
|
||||
/dist/
|
||||
nul
|
||||
56
README.md
Normal file
56
README.md
Normal file
@ -0,0 +1,56 @@
|
||||
# 3D模型展示SDK - 纯JavaScript版
|
||||
|
||||
基于 BabylonJS 的轻量模型展示 SDK,支持加载 GLB/GLTF、播放动画、控制相机/灯光,并附带截图、录制与 ARKit 调试能力。数字人和翻译/字幕相关逻辑已移除,仅保留通用的模型展示能力。
|
||||
|
||||
## 项目结构
|
||||
- `index.html`:开发示例页(直接引用 `src/main.ts`)
|
||||
- `dist/test.html`:构建后的示例页(引用 `dist/assets/index.js`)
|
||||
- `src/main.ts`:SDK 对外入口,挂载到 `window.faceSDK.kernel`
|
||||
- `src/babylonjs`、`src/managers`:引擎、场景、动画等核心模块
|
||||
|
||||
## 快速开始
|
||||
1. 安装依赖:`npm install`
|
||||
2. 启动本地预览(任选其一):
|
||||
- `npm run dev`
|
||||
- 或 `python -m http.server 8080` 后访问 `http://localhost:8080`
|
||||
3. 打开 `index.html`(开发)或 `dist/test.html`(构建产物)查看示例控制面板。
|
||||
|
||||
## 初始化示例
|
||||
```js
|
||||
await faceSDK.kernel.init({
|
||||
container: 'renderDom',
|
||||
modelUrlList: [
|
||||
'/1104_01_Exp/head_a01/head_a01.gltf',
|
||||
'/1104_01_Exp/shoes_e01/shoes_e01.gltf',
|
||||
'/1104_01_Exp/bottom_a01/bottom_a01.gltf',
|
||||
'/1104_01_Exp/hair_a01/hair_a01.gltf',
|
||||
'/1104_01_Exp/top_f01/top_f01.gltf'
|
||||
],
|
||||
animationUrlList: ['/idle/G001@Idle.glb'],
|
||||
apiConfig: { name: '你的用户名', readLocalResource: true },
|
||||
enableExpression: false,
|
||||
onSuccess: () => console.log('加载完成'),
|
||||
onError: (err) => console.error('加载失败', err)
|
||||
});
|
||||
```
|
||||
|
||||
## 常用 API
|
||||
- `playAnimation(url)` / `stopAnimation()`:加载并播放/停止动画
|
||||
- `setAnimationSpeed(speed)`:调整播放速度
|
||||
- `setCameraPreset('full' | 'head' | 'upper')` / `resetCamera()`:相机预设与重置
|
||||
- `setLightIntensity(value)`、`setHDRIntensity(value)`、`rotateHDR(angle)`、`loadHDR(path)`:灯光与环境控制
|
||||
- `captureScreenshot(type, quality)` / `downloadScreenshot(filename, type, quality)`:截图
|
||||
- `startRecording(options)` / `stopRecording()` / `downloadRecording(filename)`:录屏
|
||||
- `enableARKit()`、`showARKitPanel()`、`connectARKitServer(url)`:ARKit 表情调试(可选)
|
||||
|
||||
更多接口可查看 `src/main.ts` 注释。
|
||||
|
||||
## 示例控制面板
|
||||
- 动画 URL:输入 GLB/GLTF 动画地址并播放
|
||||
- 播放速度:0.5x - 3x
|
||||
- 相机预设:全身 / 头部 / 上半身 / 重置
|
||||
- 灯光强度:调整场景亮度
|
||||
- ARKit 调试:连接、显示面板、断开(需要本地 ARKit 数据)
|
||||
|
||||
## 许可证
|
||||
MIT License
|
||||
38
index.html
Normal file
38
index.html
Normal file
@ -0,0 +1,38 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>3D模型展示SDK - TS版</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||
}
|
||||
#app { width: 100vw; height: 100vh; position: relative; }
|
||||
#renderDom { width: 100%; height: 100%; display: block; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<canvas id="renderDom"></canvas>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
import { kernel } from '/src/main.ts';
|
||||
|
||||
const config = {
|
||||
container: 'renderDom',
|
||||
modelUrlList: ["/model/model.glb"],
|
||||
animationUrlList: [],
|
||||
idleAnimationUrlList: [],
|
||||
onSuccess: () => console.log('SDK initialized'),
|
||||
onError: (err) => console.error('SDK init error', err),
|
||||
};
|
||||
|
||||
kernel.init(config);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
113
mock-arkit-server.js
Normal file
113
mock-arkit-server.js
Normal file
@ -0,0 +1,113 @@
|
||||
/**
|
||||
* 模拟 ARKit 数据的 WebSocket 服务器
|
||||
* 运行: node mock-arkit-server.js
|
||||
*/
|
||||
|
||||
const WebSocket = require('ws');
|
||||
|
||||
const PORT = 8765;
|
||||
const wss = new WebSocket.Server({ port: PORT });
|
||||
|
||||
// ARKit 52 个 blendShape 完整列表
|
||||
const ARKIT_BLENDSHAPES = [
|
||||
'eyeBlinkLeft', 'eyeLookDownLeft', 'eyeLookInLeft', 'eyeLookOutLeft', 'eyeLookUpLeft', 'eyeSquintLeft', 'eyeWideLeft',
|
||||
'eyeBlinkRight', 'eyeLookDownRight', 'eyeLookInRight', 'eyeLookOutRight', 'eyeLookUpRight', 'eyeSquintRight', 'eyeWideRight',
|
||||
'jawForward', 'jawLeft', 'jawRight', 'jawOpen',
|
||||
'mouthClose', 'mouthFunnel', 'mouthPucker', 'mouthLeft', 'mouthRight',
|
||||
'mouthSmileLeft', 'mouthSmileRight', 'mouthFrownLeft', 'mouthFrownRight',
|
||||
'mouthDimpleLeft', 'mouthDimpleRight', 'mouthStretchLeft', 'mouthStretchRight',
|
||||
'mouthRollLower', 'mouthRollUpper', 'mouthShrugLower', 'mouthShrugUpper',
|
||||
'mouthPressLeft', 'mouthPressRight', 'mouthLowerDownLeft', 'mouthLowerDownRight',
|
||||
'mouthUpperUpLeft', 'mouthUpperUpRight',
|
||||
'browDownLeft', 'browDownRight', 'browInnerUp', 'browOuterUpLeft', 'browOuterUpRight',
|
||||
'cheekPuff', 'cheekSquintLeft', 'cheekSquintRight',
|
||||
'noseSneerLeft', 'noseSneerRight', 'tongueOut'
|
||||
];
|
||||
|
||||
// 表情预设
|
||||
const expressions = [
|
||||
{ name: '微笑', data: { mouthSmileLeft: 0.8, mouthSmileRight: 0.8, cheekSquintLeft: 0.3, cheekSquintRight: 0.3 } },
|
||||
{ name: '张嘴说话', data: { jawOpen: 0.5, mouthFunnel: 0.3 } },
|
||||
{ name: '惊讶', data: { eyeWideLeft: 0.9, eyeWideRight: 0.9, jawOpen: 0.6, browInnerUp: 0.7 } },
|
||||
{ name: '皱眉', data: { browDownLeft: 0.8, browDownRight: 0.8, eyeSquintLeft: 0.3, eyeSquintRight: 0.3 } },
|
||||
{ name: '嘟嘴', data: { mouthPucker: 0.9, mouthFunnel: 0.4 } },
|
||||
{ name: '吐舌', data: { tongueOut: 0.7, jawOpen: 0.4 } },
|
||||
{ name: '生气', data: { noseSneerLeft: 0.7, noseSneerRight: 0.7, browDownLeft: 0.6, browDownRight: 0.6, jawOpen: 0.2 } },
|
||||
{ name: '悲伤', data: { mouthFrownLeft: 0.7, mouthFrownRight: 0.7, browInnerUp: 0.5, eyeSquintLeft: 0.2, eyeSquintRight: 0.2 } },
|
||||
{ name: '中性', data: {} },
|
||||
];
|
||||
|
||||
let currentExprIndex = 0;
|
||||
let transitionProgress = 0;
|
||||
let blinkTimer = 0;
|
||||
let isBlinking = false;
|
||||
|
||||
function lerp(a, b, t) {
|
||||
return a + (b - a) * t;
|
||||
}
|
||||
|
||||
function generateBlendShapes() {
|
||||
const current = expressions[currentExprIndex].data;
|
||||
const next = expressions[(currentExprIndex + 1) % expressions.length].data;
|
||||
|
||||
// 初始化所有 52 个 blendShape 为 0
|
||||
const blendShapes = {};
|
||||
ARKIT_BLENDSHAPES.forEach(name => blendShapes[name] = 0);
|
||||
|
||||
// 插值当前和下一个表情
|
||||
for (const key of ARKIT_BLENDSHAPES) {
|
||||
const currentVal = current[key] || 0;
|
||||
const nextVal = next[key] || 0;
|
||||
blendShapes[key] = lerp(currentVal, nextVal, transitionProgress);
|
||||
}
|
||||
|
||||
// 自然眨眼(每3-5秒眨一次)
|
||||
blinkTimer++;
|
||||
if (!isBlinking && blinkTimer > 90 + Math.random() * 60) {
|
||||
isBlinking = true;
|
||||
blinkTimer = 0;
|
||||
}
|
||||
if (isBlinking) {
|
||||
const blinkProgress = blinkTimer / 6;
|
||||
if (blinkProgress < 1) {
|
||||
blendShapes.eyeBlinkLeft = Math.sin(blinkProgress * Math.PI);
|
||||
blendShapes.eyeBlinkRight = Math.sin(blinkProgress * Math.PI);
|
||||
} else {
|
||||
isBlinking = false;
|
||||
blinkTimer = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 添加微小的随机抖动(更自然)
|
||||
blendShapes.jawOpen += (Math.random() - 0.5) * 0.02;
|
||||
blendShapes.browInnerUp += (Math.random() - 0.5) * 0.01;
|
||||
|
||||
// 表情过渡
|
||||
transitionProgress += 0.015;
|
||||
if (transitionProgress >= 1) {
|
||||
transitionProgress = 0;
|
||||
currentExprIndex = (currentExprIndex + 1) % expressions.length;
|
||||
console.log(`切换到表情: ${expressions[currentExprIndex].name}`);
|
||||
}
|
||||
|
||||
return blendShapes;
|
||||
}
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
console.log('客户端已连接');
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
const data = generateBlendShapes();
|
||||
ws.send(JSON.stringify(data));
|
||||
}
|
||||
}, 33); // ~30fps
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('客户端断开');
|
||||
clearInterval(interval);
|
||||
});
|
||||
});
|
||||
|
||||
console.log(`ARKit 模拟服务器运行在 ws://localhost:${PORT}`);
|
||||
console.log('表情循环: ' + expressions.map(e => e.name).join(' -> '));
|
||||
1492
package-lock.json
generated
Normal file
1492
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
package.json
Normal file
24
package.json
Normal file
@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "client-babylonjs-pure",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build && node scripts/postbuild.cjs",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babylonjs/core": "^7.0.0",
|
||||
"@babylonjs/loaders": "^7.0.0",
|
||||
"axios": "^1.6.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"js-md5": "^0.8.3",
|
||||
"pako": "^2.1.0",
|
||||
"ws": "^8.14.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-terser": "^0.4.4",
|
||||
"typescript": "^5.4.0",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
}
|
||||
20
scripts/postbuild.cjs
Normal file
20
scripts/postbuild.cjs
Normal file
@ -0,0 +1,20 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const projectRoot = path.resolve(__dirname, '..');
|
||||
const srcIndex = path.join(projectRoot, 'index.html');
|
||||
const distDir = path.join(projectRoot, 'dist');
|
||||
const distIndex = path.join(distDir, 'index.html');
|
||||
|
||||
if (!fs.existsSync(distDir)) {
|
||||
fs.mkdirSync(distDir, { recursive: true });
|
||||
}
|
||||
|
||||
let html = fs.readFileSync(srcIndex, 'utf8');
|
||||
|
||||
// 替换开发路径为构建产物路径
|
||||
html = html.replace(/['"]\/src\/main\.ts['"]/g, '"/assets/index.js"');
|
||||
|
||||
fs.writeFileSync(distIndex, html, 'utf8');
|
||||
|
||||
console.log('postbuild: index.html written to dist/');
|
||||
17
src/apis/axios.ts
Normal file
17
src/apis/axios.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import axios, { AxiosRequestConfig, AxiosResponse } from 'axios';
|
||||
|
||||
export type HttpClient = {
|
||||
baseURL: string;
|
||||
headers: Record<string, string>;
|
||||
get: <T = unknown>(url: string, options?: AxiosRequestConfig) => Promise<AxiosResponse<T>>;
|
||||
post: <T = unknown>(url: string, data: unknown, options?: AxiosRequestConfig) => Promise<AxiosResponse<T>>;
|
||||
};
|
||||
|
||||
export const httpClient: HttpClient = {
|
||||
baseURL: 'http://localhost:3000',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
get: (url, options) => axios.get(url, options),
|
||||
post: (url, data, options = {}) => axios.post(url, data, options)
|
||||
};
|
||||
82
src/apis/services.ts
Normal file
82
src/apis/services.ts
Normal file
@ -0,0 +1,82 @@
|
||||
/**
|
||||
* @file services.js
|
||||
* @description API服务模块 - 提供与后端交互的各种接口
|
||||
*/
|
||||
|
||||
import { httpClient } from "./axios";
|
||||
import configurator from "../components/conf";
|
||||
import auth from "../components/auth";
|
||||
|
||||
type AssetFetchResult = {
|
||||
value: Uint8Array;
|
||||
timestamp: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取资源数据的通用方法
|
||||
* @param {string} url - 资源基础URL
|
||||
* @param {string} obj - 资源路径
|
||||
* @returns {Promise<{value: Uint8Array, timestamp: string}|null>}
|
||||
*/
|
||||
const fetchAssetData = async (url: string, obj: string): Promise<AssetFetchResult | null> => {
|
||||
try {
|
||||
// 获取当前时间戳
|
||||
const currentTimestamp = Date.now().toString();
|
||||
const info = auth.load();
|
||||
if (!info) return null;
|
||||
// 将参数拼接到URL中
|
||||
const fullUrl = `${url}/${encodeURI(obj)}`;
|
||||
|
||||
return await httpClient.get(fullUrl, {
|
||||
responseType: 'arraybuffer',
|
||||
headers: {
|
||||
Timestamp: currentTimestamp,
|
||||
Token: info.token ?? '',
|
||||
UUID: info.uid ?? ''
|
||||
}
|
||||
})
|
||||
.then(response => ({
|
||||
value: new Uint8Array(response.data as ArrayBuffer),
|
||||
timestamp: currentTimestamp,
|
||||
}))
|
||||
.catch(error => {
|
||||
console.error('Axios GET request error:', error);
|
||||
return null;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Fetch asset data error:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取GLB模型数据
|
||||
* @param {string} obj - 模型文件路径
|
||||
* @returns {Promise<Uint8Array|null>} 模型二进制数据
|
||||
*/
|
||||
export const fetchGlbModel = async (obj: string): Promise<Uint8Array | null> => {
|
||||
const result = await fetchAssetData(configurator.modelAssetDir, obj);
|
||||
return result?.value ?? null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 用户登录
|
||||
* @param {object} obj - 登录请求参数
|
||||
* @param {object} obj.content - 登录内容
|
||||
* @param {string} obj.content.obj1 - 用户名
|
||||
* @returns {Promise<object>} 登录响应数据
|
||||
*/
|
||||
export const userLogin = async (obj: unknown): Promise<any> => {
|
||||
const data = await httpClient.post(`${httpClient.baseURL}/api/Terminal/ThreeJSToLogin`, obj);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取动画数据
|
||||
* @param {string} obj - 动画文件路径
|
||||
* @returns {Promise<object>} 动画数据
|
||||
*/
|
||||
export const fetchAnim = async (obj: string): Promise<any> => {
|
||||
const result = await httpClient.get(`${configurator.animAssetDir}/${obj}`);
|
||||
return result.data;
|
||||
}
|
||||
52
src/babylonjs/AppCamera.ts
Normal file
52
src/babylonjs/AppCamera.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import { ArcRotateCamera } from '@babylonjs/core/Cameras/arcRotateCamera';
|
||||
import { Vector3 } from '@babylonjs/core/Maths/math.vector';
|
||||
import { Tools } from '@babylonjs/core/Misc/tools';
|
||||
import { Monobehiver } from '../base/Monobehiver';
|
||||
|
||||
/**
|
||||
* 相机控制类- 负责创建和控制弧形旋转相机
|
||||
*/
|
||||
export class AppCamera extends Monobehiver {
|
||||
object: ArcRotateCamera | null;
|
||||
|
||||
constructor(mainApp: any) {
|
||||
super(mainApp);
|
||||
this.object = null;
|
||||
}
|
||||
|
||||
/** 初始化相机 */
|
||||
Awake(): void {
|
||||
const scene = this.mainApp.appScene.object;
|
||||
const canvas = this.mainApp.appDom.renderDom;
|
||||
if (!scene || !canvas) return;
|
||||
|
||||
// 创建弧形旋转相机:水平角70度,垂直角80度,距离5,目标点(0,1,0)
|
||||
this.object = new ArcRotateCamera('Camera', Tools.ToRadians(70), Tools.ToRadians(80), 5, new Vector3(0, 0, 0), scene);
|
||||
this.object.attachControl(canvas, true);
|
||||
this.object.minZ = 0.01; // 近裁剪面
|
||||
this.object.wheelPrecision =999999; // 滚轮缩放精度
|
||||
this.object.panningSensibility = 0;
|
||||
this.object.position = new Vector3(-0, 0, 100);
|
||||
this.setTarget(0, 2, 0);
|
||||
}
|
||||
|
||||
/** 设置相机目标点 */
|
||||
setTarget(x: number, y: number, z: number): void {
|
||||
if (this.object) {
|
||||
this.object.target = new Vector3(x, y, z);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** 重置相机到默认位置 */
|
||||
reset(): void {
|
||||
if (!this.object) return;
|
||||
this.object.radius = 2; this.setTarget(0, 0, 0);
|
||||
this.object.position = new Vector3(0, 1.5, 2);
|
||||
}
|
||||
|
||||
update(): void {
|
||||
|
||||
}
|
||||
}
|
||||
13
src/babylonjs/AppConfig.ts
Normal file
13
src/babylonjs/AppConfig.ts
Normal file
@ -0,0 +1,13 @@
|
||||
|
||||
type OptionalCallback = (() => void) | null | undefined;
|
||||
type ErrorCallback = ((error?: unknown) => void) | null | undefined;
|
||||
|
||||
/**
|
||||
* 共享运行时配置对象
|
||||
*/
|
||||
export const AppConfig = {
|
||||
container: 'renderDom',
|
||||
modelUrlList: [] as string[],
|
||||
success: null as OptionalCallback,
|
||||
error: null as ErrorCallback
|
||||
};
|
||||
21
src/babylonjs/AppDom.ts
Normal file
21
src/babylonjs/AppDom.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { AppConfig } from './AppConfig';
|
||||
|
||||
/**
|
||||
* 负责获取渲染容器 DOM
|
||||
*/
|
||||
export class AppDom {
|
||||
private _renderDom: HTMLCanvasElement | null;
|
||||
|
||||
constructor() {
|
||||
this._renderDom = null;
|
||||
}
|
||||
|
||||
get renderDom(): HTMLCanvasElement | null {
|
||||
return this._renderDom;
|
||||
}
|
||||
|
||||
Awake(): void {
|
||||
const dom = document.getElementById(AppConfig.container) || document.querySelector('#renderDom');
|
||||
this._renderDom = (dom as HTMLCanvasElement) ?? null;
|
||||
}
|
||||
}
|
||||
38
src/babylonjs/AppEngin.ts
Normal file
38
src/babylonjs/AppEngin.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { Engine } from '@babylonjs/core/Engines/engine';
|
||||
import { Monobehiver } from '../base/Monobehiver';
|
||||
|
||||
/**
|
||||
* 渲染引擎管理类 - 负责创建和管理3D渲染引擎
|
||||
*/
|
||||
export class AppEngin extends Monobehiver {
|
||||
object: Engine | null;
|
||||
canvas: HTMLCanvasElement | null;
|
||||
|
||||
constructor(mainApp: any) {
|
||||
super(mainApp);
|
||||
this.object = null;
|
||||
this.canvas = null;
|
||||
}
|
||||
|
||||
Awake(): void {
|
||||
this.canvas = this.mainApp.appDom.renderDom;
|
||||
if (!this.canvas) {
|
||||
throw new Error('Render canvas not found');
|
||||
}
|
||||
this.object = new Engine(this.canvas, true, {
|
||||
preserveDrawingBuffer: false, // 不保留绘图缓冲区
|
||||
stencil: true, // 启用模板缓冲
|
||||
alpha: true // 启用透明背景
|
||||
});
|
||||
this.object.setSize(window.innerWidth, window.innerHeight);
|
||||
this.object.setHardwareScalingLevel(1); // 1:1像素比例
|
||||
}
|
||||
|
||||
/** 处理窗口大小变化 */
|
||||
handleResize(): void {
|
||||
if (this.object) {
|
||||
this.object.setSize(window.innerWidth, window.innerHeight);
|
||||
this.object.resize();
|
||||
}
|
||||
}
|
||||
}
|
||||
58
src/babylonjs/AppEnv.ts
Normal file
58
src/babylonjs/AppEnv.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import { CubeTexture } from '@babylonjs/core/Materials/Textures/cubeTexture';
|
||||
import { Monobehiver } from '../base/Monobehiver';
|
||||
|
||||
/**
|
||||
* 环境管理类- 负责创建和管理HDR环境贴图
|
||||
*/
|
||||
export class AppEnv extends Monobehiver {
|
||||
object: CubeTexture | null;
|
||||
|
||||
constructor(mainApp: any) {
|
||||
super(mainApp);
|
||||
this.object = null;
|
||||
}
|
||||
|
||||
/** 初始化 - 创建默认HDR环境 */
|
||||
Awake(): void {
|
||||
this.createHDR();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建HDR环境贴图
|
||||
* @param hdrPath HDR文件路径
|
||||
*/
|
||||
createHDR(hdrPath = '/hdr/sanGiuseppeBridge.env'): void {
|
||||
const scene = this.mainApp.appScene.object;
|
||||
if (!scene) return;
|
||||
const reflectionTexture = CubeTexture.CreateFromPrefilteredData(hdrPath, scene);
|
||||
scene.environmentIntensity = 1.5;
|
||||
scene.environmentTexture = reflectionTexture;
|
||||
this.object = reflectionTexture;
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改HDR环境光强度
|
||||
* @param intensity 强度值
|
||||
*/
|
||||
changeHDRIntensity(intensity: number): void {
|
||||
if (this.mainApp.appScene.object) {
|
||||
this.mainApp.appScene.object.environmentIntensity = intensity;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 旋转HDR环境贴图
|
||||
* @param angle 旋转角度(弧度)
|
||||
*/
|
||||
rotateHDR(angle: number): void {
|
||||
if (this.object) this.object.rotationY = angle;
|
||||
}
|
||||
|
||||
/** 清理资源 */
|
||||
clean(): void {
|
||||
if (this.object) {
|
||||
this.object.dispose();
|
||||
this.object = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
209
src/babylonjs/AppLight.ts
Normal file
209
src/babylonjs/AppLight.ts
Normal file
@ -0,0 +1,209 @@
|
||||
import { SpotLight } from '@babylonjs/core/Lights/spotLight';
|
||||
import { ShadowGenerator } from '@babylonjs/core/Lights/Shadows/shadowGenerator';
|
||||
import '@babylonjs/core/Lights/Shadows/shadowGeneratorSceneComponent';
|
||||
import { Vector3, Quaternion } from '@babylonjs/core/Maths/math.vector';
|
||||
import { Monobehiver } from '../base/Monobehiver';
|
||||
import { MeshBuilder } from '@babylonjs/core/Meshes/meshBuilder';
|
||||
import { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial';
|
||||
import { Color3 } from '@babylonjs/core/Maths/math.color';
|
||||
import { GizmoManager } from '@babylonjs/core/Gizmos/gizmoManager';
|
||||
import { AbstractMesh } from '@babylonjs/core/Meshes/abstractMesh';
|
||||
import { Mesh } from '@babylonjs/core/Meshes/mesh';
|
||||
|
||||
type DebugMarkers = {
|
||||
marker: Mesh;
|
||||
arrow: Mesh;
|
||||
gizmoManager: GizmoManager;
|
||||
onKey: (e: KeyboardEvent) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* 灯光管理类- 负责创建和管理场景灯光
|
||||
*/
|
||||
export class AppLight extends Monobehiver {
|
||||
lightList: SpotLight[];
|
||||
shadowGenerator: ShadowGenerator | null;
|
||||
debugMarkers?: DebugMarkers;
|
||||
coneMesh?: Mesh;
|
||||
updateCone?: () => void;
|
||||
|
||||
constructor(mainApp: any) {
|
||||
super(mainApp);
|
||||
this.lightList = [];
|
||||
this.shadowGenerator = null;
|
||||
}
|
||||
|
||||
/** 初始化灯光并开启阴影 */
|
||||
Awake(): void {
|
||||
const light = new SpotLight(
|
||||
"mainLight",
|
||||
new Vector3(-0.6, 2.12, 2),
|
||||
new Vector3(0, -0.5, -1),
|
||||
Math.PI * 0.6, // angle 弧度
|
||||
);
|
||||
|
||||
light.angle = 1.5;
|
||||
light.innerAngle = 1;
|
||||
light.exponent = 2;
|
||||
light.diffuse = new Color3(1, 0.86, 0.80);
|
||||
light.specular = new Color3(1, 1, 1);
|
||||
light.intensity = 60;
|
||||
light.shadowMinZ = 0.01;
|
||||
light.shadowMaxZ = 100;
|
||||
light.range = 5000;
|
||||
|
||||
const generator = new ShadowGenerator(4096, light);
|
||||
generator.usePercentageCloserFiltering = true;
|
||||
generator.filteringQuality = ShadowGenerator.QUALITY_HIGH;
|
||||
generator.transparencyShadow = true;
|
||||
|
||||
this.lightList.push(light);
|
||||
this.shadowGenerator = generator;
|
||||
}
|
||||
|
||||
/** 将网格添加为阴影投射者 */
|
||||
addShadowCaster(mesh: AbstractMesh): void {
|
||||
if (this.shadowGenerator) {
|
||||
this.shadowGenerator.addShadowCaster(mesh);
|
||||
}
|
||||
}
|
||||
|
||||
/** 设置主光源强度 */
|
||||
setIntensity(intensity: number): void {
|
||||
if (this.lightList[0]) this.lightList[0].intensity = intensity;
|
||||
}
|
||||
|
||||
/** 创建灯光可视化调试器 - W键拖拽位置,E键旋转方向 */
|
||||
enableLightDebug(): void {
|
||||
const scene = this.mainApp.appScene.object;
|
||||
const light = this.lightList[0];
|
||||
if (!light || !scene) return;
|
||||
|
||||
const marker = MeshBuilder.CreateSphere("lightMarker", { diameter: 0.3 }, scene);
|
||||
marker.position = light.position.clone();
|
||||
const mat = new StandardMaterial("lightMat", scene);
|
||||
mat.emissiveColor = Color3.Yellow();
|
||||
marker.material = mat;
|
||||
|
||||
const arrow = MeshBuilder.CreateCylinder("lightArrow", { height: 1, diameterTop: 0, diameterBottom: 0.1 }, scene);
|
||||
arrow.parent = marker;
|
||||
arrow.position.set(0, 0, 0.6);
|
||||
arrow.rotation.x = Math.PI / 2;
|
||||
const arrowMat = new StandardMaterial("arrowMat", scene);
|
||||
arrowMat.emissiveColor = Color3.Red();
|
||||
arrow.material = arrowMat;
|
||||
|
||||
const dir = light.direction.normalize();
|
||||
marker.rotation.y = Math.atan2(dir.x, dir.z);
|
||||
marker.rotation.x = -Math.asin(dir.y);
|
||||
|
||||
const gizmoManager = new GizmoManager(scene);
|
||||
gizmoManager.attachableMeshes = [marker];
|
||||
gizmoManager.usePointerToAttachGizmos = false;
|
||||
gizmoManager.attachToMesh(marker);
|
||||
|
||||
scene.onBeforeRenderObservable.add(() => {
|
||||
light.position.copyFrom(marker.position);
|
||||
const forward = new Vector3(0, 0, 1);
|
||||
const rotationMatrix = marker.getWorldMatrix().getRotationMatrix();
|
||||
light.direction = Vector3.TransformNormal(forward, rotationMatrix).normalize();
|
||||
});
|
||||
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'w' || e.key === 'W') {
|
||||
gizmoManager.positionGizmoEnabled = true;
|
||||
gizmoManager.rotationGizmoEnabled = false;
|
||||
} else if (e.key === 'e' || e.key === 'E') {
|
||||
gizmoManager.positionGizmoEnabled = false;
|
||||
gizmoManager.rotationGizmoEnabled = true;
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
gizmoManager.positionGizmoEnabled = true;
|
||||
|
||||
this.debugMarkers = { marker, arrow, gizmoManager, onKey };
|
||||
}
|
||||
|
||||
/** 隐藏灯光调试器 */
|
||||
disableLightDebug(): void {
|
||||
if (this.debugMarkers) {
|
||||
window.removeEventListener('keydown', this.debugMarkers.onKey);
|
||||
this.debugMarkers.gizmoManager.dispose();
|
||||
this.debugMarkers.arrow.dispose();
|
||||
this.debugMarkers.marker.dispose();
|
||||
this.debugMarkers = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** 创建聚光灯可视化Gizmo - 带光锥范围 */
|
||||
createLightGizmo(): void {
|
||||
const scene = this.mainApp.appScene.object;
|
||||
const light = this.lightList[0];
|
||||
if (!light || !scene) return;
|
||||
|
||||
const coneLength = 3;
|
||||
const updateCone = () => {
|
||||
if (this.coneMesh) this.coneMesh.dispose();
|
||||
const radius = Math.tan(light.angle) * coneLength;
|
||||
const cone = MeshBuilder.CreateCylinder("lightCone", {
|
||||
height: coneLength,
|
||||
diameterTop: radius * 2,
|
||||
diameterBottom: 0
|
||||
}, scene);
|
||||
const mat = new StandardMaterial("coneMat", scene);
|
||||
mat.emissiveColor = Color3.Yellow();
|
||||
mat.alpha = 0.2;
|
||||
mat.wireframe = true;
|
||||
cone.material = mat;
|
||||
|
||||
cone.position = light.position.add(light.direction.scale(coneLength / 2));
|
||||
const up = new Vector3(0, 1, 0);
|
||||
const axis = Vector3.Cross(up, light.direction).normalize();
|
||||
const angle = Math.acos(Vector3.Dot(up, light.direction.normalize()));
|
||||
if (axis.length() > 0.001) cone.rotationQuaternion = Quaternion.RotationAxis(axis, angle);
|
||||
|
||||
this.coneMesh = cone;
|
||||
};
|
||||
|
||||
updateCone();
|
||||
this.updateCone = updateCone;
|
||||
}
|
||||
|
||||
/** 创建angle和innerAngle调试滑动条 */
|
||||
createAngleSliders(): void {
|
||||
const light = this.lightList[0];
|
||||
if (!light) return;
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.style.cssText = 'position:fixed;top:10px;right:10px;background:rgba(0,0,0,0.7);padding:10px;border-radius:5px;color:#fff;font-size:12px;z-index:1000';
|
||||
|
||||
const createSlider = (label: string, value: number, min: number, max: number, onChange: (v: number) => void) => {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.style.marginBottom = '8px';
|
||||
const lbl = document.createElement('div');
|
||||
lbl.textContent = `${label}: ${value}`;
|
||||
const slider = document.createElement('input');
|
||||
slider.type = 'range';
|
||||
slider.min = String(min);
|
||||
slider.max = String(max);
|
||||
slider.value = String(value);
|
||||
slider.style.width = '150px';
|
||||
slider.oninput = () => {
|
||||
lbl.textContent = `${label}: ${slider.value}`;
|
||||
onChange(Number(slider.value));
|
||||
};
|
||||
wrap.append(lbl, slider);
|
||||
return wrap;
|
||||
};
|
||||
|
||||
const toRad = (deg: number) => deg * Math.PI / 180;
|
||||
const toDeg = (rad: number) => Math.round(rad * 180 / Math.PI);
|
||||
|
||||
container.append(
|
||||
createSlider('angle', toDeg(light.angle), 1, 180, v => { light.angle = toRad(v); this.updateCone?.(); }),
|
||||
createSlider('innerAngle', toDeg(light.innerAngle), 0, 180, v => { light.innerAngle = toRad(v); })
|
||||
);
|
||||
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
}
|
||||
119
src/babylonjs/AppModel.ts
Normal file
119
src/babylonjs/AppModel.ts
Normal file
@ -0,0 +1,119 @@
|
||||
import { ImportMeshAsync } from '@babylonjs/core/Loading/sceneLoader';
|
||||
import '@babylonjs/loaders/glTF';
|
||||
import { AbstractMesh } from '@babylonjs/core/Meshes/abstractMesh';
|
||||
import { Scene } from '@babylonjs/core/scene';
|
||||
import { Monobehiver } from '../base/Monobehiver';
|
||||
import { Dictionary } from '../utils/Dictionary';
|
||||
import { AppConfig } from './AppConfig';
|
||||
|
||||
type LoadResult = {
|
||||
success: boolean;
|
||||
meshes?: AbstractMesh[];
|
||||
skeletons?: unknown[];
|
||||
error?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 模型管理类- 负责加载、缓存和管理3D模型
|
||||
*/
|
||||
export class AppModel extends Monobehiver {
|
||||
private modelDic: Dictionary<AbstractMesh[]>;
|
||||
private loadedMeshes: AbstractMesh[];
|
||||
private skeletonManager: any;
|
||||
private outfitManager: any;
|
||||
private isLoading: boolean;
|
||||
private skeletonMerged: boolean;
|
||||
|
||||
constructor(mainApp: any) {
|
||||
super(mainApp);
|
||||
this.modelDic = new Dictionary<AbstractMesh[]>();
|
||||
this.loadedMeshes = [];
|
||||
this.skeletonManager = null;
|
||||
this.outfitManager = null;
|
||||
this.isLoading = false;
|
||||
this.skeletonMerged = false;
|
||||
}
|
||||
|
||||
/** 初始化子管理器(占位:实际实现已移除) */
|
||||
initManagers(): void {
|
||||
// 这里原本会初始化 SkeletonManager 和 OutfitManager,已留空以避免恢复已删除的实现
|
||||
}
|
||||
|
||||
/** 加载配置中的所有模型 */
|
||||
async loadModel(): Promise<void> {
|
||||
if (!AppConfig.modelUrlList?.length || this.isLoading) return;
|
||||
this.isLoading = true;
|
||||
try {
|
||||
for (const url of AppConfig.modelUrlList) {
|
||||
await this.loadSingleModel(url);
|
||||
}
|
||||
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载单个模型
|
||||
* @param modelUrl 模型URL
|
||||
*/
|
||||
async loadSingleModel(modelUrl: string): Promise<LoadResult> {
|
||||
try {
|
||||
const cached = this.getCachedMeshes(modelUrl);
|
||||
if (cached) return { success: true, meshes: cached };
|
||||
|
||||
const scene: Scene | null = this.mainApp.appScene.object;
|
||||
if (!scene) return { success: false, error: '场景未初始化' };
|
||||
|
||||
// ImportMeshAsync的签名与当前调用不完全一致,使用any规避编译报错
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const result: any = await (ImportMeshAsync as any)(modelUrl, scene);
|
||||
if (!result?.meshes?.length) return { success: false, error: '未找到网格' };
|
||||
|
||||
this.modelDic.Set(modelUrl, result.meshes);
|
||||
this.loadedMeshes.push(...result.meshes);
|
||||
|
||||
this.setupShadows(result.meshes as AbstractMesh[]);
|
||||
|
||||
return { success: true, meshes: result.meshes, skeletons: result.skeletons };
|
||||
} catch (e: any) {
|
||||
console.error(`模型加载失败: ${modelUrl}`, e);
|
||||
return { success: false, error: e?.message };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/** 为网格设置阴影(投射和接收) */
|
||||
setupShadows(meshes: AbstractMesh[]): void {
|
||||
const appLight = this.mainApp.appLight;
|
||||
if (!appLight) return;
|
||||
|
||||
meshes.forEach(mesh => {
|
||||
if (mesh.getTotalVertices() > 0) {
|
||||
appLight.addShadowCaster(mesh);
|
||||
mesh.receiveShadows = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 获取缓存的网格 */
|
||||
getCachedMeshes(url: string): AbstractMesh[] | undefined {
|
||||
return this.modelDic.Get(url);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/** 清理所有资源 */
|
||||
clean(): void {
|
||||
this.modelDic.Clear();
|
||||
this.loadedMeshes.forEach(m => m?.dispose());
|
||||
this.loadedMeshes = [];
|
||||
this.skeletonManager?.clean();
|
||||
this.outfitManager?.clean();
|
||||
this.isLoading = false;
|
||||
this.skeletonMerged = false;
|
||||
}
|
||||
}
|
||||
28
src/babylonjs/AppScene.ts
Normal file
28
src/babylonjs/AppScene.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { Scene } from '@babylonjs/core/scene';
|
||||
import { ImageProcessingConfiguration } from '@babylonjs/core/Materials';
|
||||
import { Color4 } from '@babylonjs/core/Maths/math.color';
|
||||
import { Monobehiver } from '../base/Monobehiver';
|
||||
|
||||
/**
|
||||
* 场景管理类- 负责创建和管理3D场景
|
||||
*/
|
||||
export class AppScene extends Monobehiver {
|
||||
object: Scene | null;
|
||||
|
||||
constructor(mainApp: any) {
|
||||
super(mainApp);
|
||||
this.object = null;
|
||||
}
|
||||
|
||||
/** 初始化场景 */
|
||||
Awake(): void {
|
||||
this.object = new Scene(this.mainApp.appEngin.object);
|
||||
this.object.clearColor = new Color4(0, 0, 0, 0); // 透明背景
|
||||
this.object.skipFrustumClipping = true; // 跳过视锥剔除优化性能
|
||||
// 1. 开启色调映射(Tone mapping)
|
||||
// this.object.imageProcessingConfiguration.toneMappingEnabled = true;
|
||||
|
||||
// 2. 设置色调映射类型为ACES
|
||||
// this.object.imageProcessingConfiguration.toneMappingType = ImageProcessingConfiguration.TONEMAPPING_ACES;
|
||||
}
|
||||
}
|
||||
83
src/babylonjs/MainApp.ts
Normal file
83
src/babylonjs/MainApp.ts
Normal file
@ -0,0 +1,83 @@
|
||||
/**
|
||||
* @file MainApp.ts
|
||||
* @description 主应用类,负责初始化和协调所有子模块
|
||||
*/
|
||||
|
||||
import { AppDom } from './AppDom';
|
||||
import { AppEngin } from './AppEngin';
|
||||
import { AppScene } from './AppScene';
|
||||
import { AppCamera } from './AppCamera';
|
||||
import { AppLight } from './AppLight';
|
||||
import { AppEnv } from './AppEnv';
|
||||
import { AppModel } from './AppModel';
|
||||
import { AppConfig } from './AppConfig';
|
||||
|
||||
/**
|
||||
* 主应用类 - 3D场景的核心控制器
|
||||
* 负责管理DOM、引擎、场景、相机、灯光、环境、模型和动画等子模块
|
||||
*/
|
||||
export class MainApp {
|
||||
appDom: AppDom;
|
||||
appEngin: AppEngin;
|
||||
appScene: AppScene;
|
||||
appCamera: AppCamera;
|
||||
appModel: AppModel;
|
||||
appLight: AppLight;
|
||||
appEnv: AppEnv;
|
||||
|
||||
|
||||
constructor() {
|
||||
this.appDom = new AppDom();
|
||||
this.appEngin = new AppEngin(this);
|
||||
this.appScene = new AppScene(this);
|
||||
this.appCamera = new AppCamera(this);
|
||||
this.appModel = new AppModel(this);
|
||||
this.appLight = new AppLight(this);
|
||||
this.appEnv = new AppEnv(this);
|
||||
|
||||
window.addEventListener("resize", () => this.appEngin.handleResize());
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载应用配置
|
||||
* @param config 配置对象
|
||||
*/
|
||||
loadAConfig(config: any): void {
|
||||
AppConfig.container = config.container || 'renderDom';
|
||||
AppConfig.modelUrlList = config.modelUrlList || [];
|
||||
AppConfig.success = config.success;
|
||||
AppConfig.error = config.error;
|
||||
}
|
||||
|
||||
loadModel(): void {
|
||||
this.appModel.loadModel();
|
||||
}
|
||||
|
||||
/** 唤醒/初始化所有子模块 */
|
||||
async Awake(): Promise<void> {
|
||||
this.appDom.Awake();
|
||||
this.appEngin.Awake();
|
||||
this.appScene.Awake();
|
||||
this.appCamera.Awake();
|
||||
this.appLight.Awake();
|
||||
this.appEnv.Awake();
|
||||
|
||||
this.appModel.initManagers();
|
||||
this.update();
|
||||
}
|
||||
|
||||
/** 启动渲染循环 */
|
||||
update(): void {
|
||||
if (!this.appEngin.object) return;
|
||||
this.appEngin.object.runRenderLoop(() => {
|
||||
this.appScene.object?.render();
|
||||
this.appCamera.update();
|
||||
});
|
||||
}
|
||||
|
||||
/** 销毁应用,释放所有资源 */
|
||||
async dispose(): Promise<void> {
|
||||
this.appModel?.clean();
|
||||
this.appEnv?.clean();
|
||||
}
|
||||
}
|
||||
14
src/base/Monobehiver.ts
Normal file
14
src/base/Monobehiver.ts
Normal file
@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 组件基础类 - 提供生命周期方法
|
||||
*/
|
||||
export class Monobehiver<TMainApp = any> {
|
||||
protected mainApp: TMainApp;
|
||||
|
||||
constructor(mainApp: TMainApp) {
|
||||
this.mainApp = mainApp;
|
||||
}
|
||||
|
||||
/** 醒来/初始化,子类可重写 */
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
Awake(): void {}
|
||||
}
|
||||
65
src/components/auth.ts
Normal file
65
src/components/auth.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import { userLogin } from "../apis/services";
|
||||
|
||||
export type AuthCache = {
|
||||
uid: string;
|
||||
eid: string;
|
||||
privilege: string;
|
||||
token: string;
|
||||
};
|
||||
|
||||
type LoginResponse = {
|
||||
result?: number;
|
||||
content?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 认证模块 - 处理用户登录和凭证信息
|
||||
*/
|
||||
const auth = {
|
||||
cache: null as AuthCache | null,
|
||||
|
||||
/** 保存认证信息 */
|
||||
save(value: AuthCache): void {
|
||||
this.cache = value;
|
||||
},
|
||||
|
||||
/** 获取缓存的认证信息 */
|
||||
load(): AuthCache | null {
|
||||
return this.cache;
|
||||
},
|
||||
|
||||
/**
|
||||
* 用户登录
|
||||
* @param name 用户名
|
||||
*/
|
||||
login: async function (name: string): Promise<AuthCache | null> {
|
||||
const normalizedName = typeof name === 'string' ? name.trim() : '';
|
||||
if (!normalizedName) {
|
||||
console.error('登录用户名不能为空');
|
||||
return null;
|
||||
}
|
||||
const loginReq = { content: { obj1: normalizedName } };
|
||||
try {
|
||||
const loginResp = await userLogin(loginReq) as LoginResponse;
|
||||
|
||||
if (loginResp?.result === 1 && typeof loginResp.content === 'string') {
|
||||
const parsed = loginResp.content
|
||||
.split(';')
|
||||
.map((item) => item.split('=')[1]);
|
||||
|
||||
const [, uid, eid, privilege, token] = parsed;
|
||||
if (uid && eid && privilege && token) {
|
||||
const userData: AuthCache = { uid, eid, privilege, token };
|
||||
this.save(userData);
|
||||
return userData;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export default auth;
|
||||
79
src/components/conf.ts
Normal file
79
src/components/conf.ts
Normal file
@ -0,0 +1,79 @@
|
||||
import { httpClient } from "../apis/axios";
|
||||
|
||||
export type ConfiguratorParams = {
|
||||
name: string;
|
||||
network?: string;
|
||||
modelName?: string;
|
||||
testModelName?: boolean;
|
||||
readLocalResource?: boolean;
|
||||
modelDir?: boolean;
|
||||
binDir?: boolean;
|
||||
gltfDir?: boolean;
|
||||
animDir?: boolean;
|
||||
};
|
||||
|
||||
export type Configurator = {
|
||||
record: boolean;
|
||||
binAssetDir: string;
|
||||
gltfAssetDir: string;
|
||||
animAssetDir: string;
|
||||
modelAssetDir: string;
|
||||
isOnline: boolean;
|
||||
modelName: string;
|
||||
screenshot: boolean;
|
||||
targetModelName?: string;
|
||||
init: (params: ConfiguratorParams) => Promise<boolean>;
|
||||
};
|
||||
|
||||
const configurator: Configurator = {
|
||||
record: false,
|
||||
binAssetDir: 'statics/glb/',
|
||||
gltfAssetDir: 'statics/gltf',
|
||||
animAssetDir: 'statics/anim',
|
||||
modelAssetDir: 'statics/models',
|
||||
isOnline: false,
|
||||
modelName: '',
|
||||
screenshot: false,
|
||||
targetModelName: undefined,
|
||||
|
||||
/** 初始化应用配置 */
|
||||
init: async function (params: ConfiguratorParams): Promise<boolean> {
|
||||
if (typeof params !== 'object' || params === null) {
|
||||
console.error("params must be an object");
|
||||
return false;
|
||||
}
|
||||
if (typeof params.name !== 'string' || params.name.length < 1) {
|
||||
console.error("params.name must be a string");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (params.modelName !== undefined) {
|
||||
configurator.modelName = params.modelName;
|
||||
}
|
||||
|
||||
if (params.testModelName === true) {
|
||||
configurator.targetModelName = params.modelName;
|
||||
}
|
||||
|
||||
if (params.network !== undefined) {
|
||||
httpClient.baseURL = params.network;
|
||||
}
|
||||
|
||||
if (params.readLocalResource === false) {
|
||||
configurator.isOnline = true;
|
||||
configurator.modelAssetDir = `${httpClient.baseURL}/api/v2/assets/sign/latest/glb`;
|
||||
configurator.binAssetDir = `${httpClient.baseURL}/api/v3/assets/sign/latest/glb/bin/`;
|
||||
configurator.gltfAssetDir = `${httpClient.baseURL}/api/v3/assets/sign/latest/glb`;
|
||||
configurator.animAssetDir = `${httpClient.baseURL}/api/v3/assets/sign/latest/glb/anim`;
|
||||
} else {
|
||||
if (params.modelDir === false) configurator.modelAssetDir = `${httpClient.baseURL}/api/v2/assets/sign/latest/glb`;
|
||||
if (params.binDir === false) configurator.binAssetDir = `${httpClient.baseURL}/api/v3/assets/sign/latest/glb/bin/`;
|
||||
if (params.gltfDir === false) configurator.gltfAssetDir = `${httpClient.baseURL}/api/v3/assets/sign/latest/glb`;
|
||||
if (params.animDir === false) configurator.animAssetDir = `${httpClient.baseURL}/api/v3/assets/sign/latest/glb/anim`;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
export default configurator;
|
||||
74
src/main.ts
Normal file
74
src/main.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import { MainApp } from './babylonjs/MainApp';
|
||||
import configurator, { ConfiguratorParams } from './components/conf';
|
||||
import auth from './components/auth';
|
||||
import { on } from './utils/event';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
faceSDK?: Record<string, unknown>;
|
||||
yiyu?: Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
|
||||
type InitParams = {
|
||||
container?: string;
|
||||
modelUrlList?: string[];
|
||||
animationUrlList?: string[];
|
||||
idleAnimationUrlList?: string[];
|
||||
onSuccess?: () => void;
|
||||
onError?: (error?: unknown) => void;
|
||||
apiConfig?: ConfiguratorParams;
|
||||
};
|
||||
|
||||
let mainApp: MainApp | null = null;
|
||||
|
||||
const kernel = {
|
||||
/** 初始化应用 */
|
||||
init: async function (params: InitParams): Promise<void> {
|
||||
if (!params) { console.error('params is required'); return; }
|
||||
|
||||
if (params.apiConfig) {
|
||||
await configurator.init(params.apiConfig);
|
||||
if (params.apiConfig.name) {
|
||||
const userInfo = await auth.login(params.apiConfig.name);
|
||||
if (!userInfo) {
|
||||
console.error('failed to fetch user');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mainApp = new MainApp();
|
||||
mainApp.loadAConfig({
|
||||
container: params.container || 'renderDom',
|
||||
modelUrlList: params.modelUrlList || [],
|
||||
success: params.onSuccess ?? null,
|
||||
error: params.onError ?? null
|
||||
});
|
||||
|
||||
await mainApp.Awake();
|
||||
await mainApp.loadModel();
|
||||
|
||||
this.debugMorphTargets();
|
||||
}
|
||||
};
|
||||
|
||||
if (!window.faceSDK) {
|
||||
window.faceSDK = {};
|
||||
}
|
||||
window.faceSDK.kernel = kernel;
|
||||
|
||||
if (!window.yiyu) {
|
||||
window.yiyu = {};
|
||||
}
|
||||
window.yiyu.kernel = kernel;
|
||||
|
||||
window.yiyu.onAppLoaded = () => { };
|
||||
window.yiyu.onSingleSignFinished = (_text: string) => { };
|
||||
window.yiyu.onSentenceFinished = (_text: string) => { };
|
||||
window.yiyu.onSentenceChanged = (_data: unknown) => { };
|
||||
window.yiyu.onGloss = (_gloss: unknown) => { };
|
||||
|
||||
window.onload = () => { };
|
||||
|
||||
export { kernel };
|
||||
19
src/managers/BaseManager.ts
Normal file
19
src/managers/BaseManager.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { Monobehiver } from '../base/Monobehiver';
|
||||
import { Dictionary } from '../utils/Dictionary';
|
||||
|
||||
/**
|
||||
* 管理器基类 - 提供通用的初始化和缓存能力
|
||||
*/
|
||||
export class BaseManager<TMainApp = any> extends Monobehiver<TMainApp> {
|
||||
protected meshCache = new Dictionary<unknown>();
|
||||
protected isInitialized = false;
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (this.isInitialized) return;
|
||||
this.isInitialized = true;
|
||||
}
|
||||
|
||||
clean(): void {
|
||||
this.meshCache.Clear();
|
||||
}
|
||||
}
|
||||
3
src/types/js-md5.d.ts
vendored
Normal file
3
src/types/js-md5.d.ts
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
declare module 'js-md5' {
|
||||
export default function md5(message: string | ArrayBuffer | ArrayBufferView): string;
|
||||
}
|
||||
50
src/utils/Dictionary.ts
Normal file
50
src/utils/Dictionary.ts
Normal file
@ -0,0 +1,50 @@
|
||||
/**
|
||||
* @file Dictionary.js
|
||||
* @description 字典工具类 - 键值对存储
|
||||
*/
|
||||
|
||||
/**
|
||||
* 字典类 - 提供类似Map的键值对存储功能
|
||||
*/
|
||||
export class Dictionary<T = unknown> {
|
||||
private items: Record<string, T> = {};
|
||||
|
||||
/** 检查是否存在指定键 */
|
||||
Has(key: string): boolean {
|
||||
return key in this.items;
|
||||
}
|
||||
|
||||
/** 设置键值对 */
|
||||
Set(key: string, value: T): void {
|
||||
this.items[key] = value;
|
||||
}
|
||||
|
||||
/** 移除指定键 */
|
||||
Remove(key: string): boolean {
|
||||
if (this.Has(key)) {
|
||||
delete this.items[key];
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 获取指定键的值 */
|
||||
Get(key: string): T | undefined {
|
||||
return this.Has(key) ? this.items[key] : undefined;
|
||||
}
|
||||
|
||||
/** 获取所有键 */
|
||||
Keys(): string[] {
|
||||
return Object.keys(this.items);
|
||||
}
|
||||
|
||||
/** 获取所有值 */
|
||||
Values(): T[] {
|
||||
return Object.values(this.items);
|
||||
}
|
||||
|
||||
/** 清空字典 */
|
||||
Clear(): void {
|
||||
this.items = {};
|
||||
}
|
||||
}
|
||||
25
src/utils/compressor.ts
Normal file
25
src/utils/compressor.ts
Normal file
@ -0,0 +1,25 @@
|
||||
/**
|
||||
* @file compressor.js
|
||||
* @description 解压缩工具,使用pako完成与GoBetterStudio相同的deflate/raw解码
|
||||
*/
|
||||
|
||||
import pako from 'pako';
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
export const compressor = {
|
||||
/** 压缩字符串或字节数据 */
|
||||
compress(data: Uint8Array | string): Uint8Array {
|
||||
const source = typeof data === 'string' ? new TextEncoder().encode(data) : data;
|
||||
return pako.deflateRaw(source);
|
||||
},
|
||||
|
||||
/** 解压缩字节数据并返回字符串 */
|
||||
decompress(data: ArrayBuffer | Uint8Array): string {
|
||||
const input = data instanceof Uint8Array ? data : new Uint8Array(data);
|
||||
const uncompressed = pako.inflateRaw(input);
|
||||
return decoder.decode(uncompressed);
|
||||
}
|
||||
};
|
||||
|
||||
export default compressor;
|
||||
73
src/utils/cryptor.ts
Normal file
73
src/utils/cryptor.ts
Normal file
@ -0,0 +1,73 @@
|
||||
import md5 from 'js-md5';
|
||||
|
||||
const KEY_SIZE = 256 / 32;
|
||||
const ITERATIONS = 1000;
|
||||
|
||||
type EncryptedAsset = {
|
||||
value: Uint8Array;
|
||||
Timestamp: string;
|
||||
};
|
||||
|
||||
type Credential = {
|
||||
token: string;
|
||||
uid: string;
|
||||
};
|
||||
|
||||
type CryptoMaterial = {
|
||||
obj: Uint8Array;
|
||||
iv: Uint8Array;
|
||||
salt: Uint8Array;
|
||||
key: string;
|
||||
};
|
||||
|
||||
function prepareCryptoMaterial(data: EncryptedAsset, info: Credential): CryptoMaterial {
|
||||
const userIdPrefix = info.uid.slice(0, 16);
|
||||
const iv = new TextEncoder().encode(userIdPrefix);
|
||||
const obj = data.value.slice(7);
|
||||
const saltHex = md5(`${info.token}${data.Timestamp}`);
|
||||
const saltBytes = new Uint8Array((saltHex.match(/.{1,2}/g) ?? []).map(byte => parseInt(byte, 16)));
|
||||
return { obj, iv, salt: saltBytes, key: info.token };
|
||||
}
|
||||
|
||||
/** 从服务器返回的数据中异步解密出原始GLTF内容 */
|
||||
export async function decryptAsync(data: EncryptedAsset, info: Credential): Promise<ArrayBuffer | null> {
|
||||
const { obj, iv, salt, key } = prepareCryptoMaterial(data, info);
|
||||
const derivedKey = await generateAesKeyAsync(key, salt, KEY_SIZE, ITERATIONS);
|
||||
return aesDecryptAsync(obj, derivedKey, iv);
|
||||
}
|
||||
|
||||
async function generateAesKeyAsync(secret: string, salt: Uint8Array, keySize: number, iterations: number): Promise<CryptoKey> {
|
||||
const passwordBuffer = new TextEncoder().encode(secret);
|
||||
const baseKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
passwordBuffer,
|
||||
{ name: 'PBKDF2' },
|
||||
false,
|
||||
['deriveBits', 'deriveKey']
|
||||
);
|
||||
return crypto.subtle.deriveKey(
|
||||
{
|
||||
name: 'PBKDF2',
|
||||
salt,
|
||||
iterations,
|
||||
hash: 'SHA-1'
|
||||
},
|
||||
baseKey,
|
||||
{ name: 'AES-CBC', length: keySize * 32 },
|
||||
false,
|
||||
['decrypt']
|
||||
);
|
||||
}
|
||||
|
||||
async function aesDecryptAsync(data: Uint8Array, key: CryptoKey, iv: Uint8Array): Promise<ArrayBuffer | null> {
|
||||
try {
|
||||
return await crypto.subtle.decrypt(
|
||||
{ name: 'AES-CBC', iv },
|
||||
key,
|
||||
data
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('解密失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
123
src/utils/event.ts
Normal file
123
src/utils/event.ts
Normal file
@ -0,0 +1,123 @@
|
||||
type Listener = {
|
||||
callback: (...args: unknown[]) => void;
|
||||
context?: unknown;
|
||||
};
|
||||
|
||||
type EventMeta = {
|
||||
type: string;
|
||||
description: string;
|
||||
listeners: Listener[];
|
||||
};
|
||||
|
||||
/**
|
||||
* 基础事件发射器
|
||||
*/
|
||||
export class Emitter {
|
||||
private _events: Record<string, Listener[]> = {};
|
||||
|
||||
on(name: string, callback: (...args: unknown[]) => void, context?: unknown): this {
|
||||
if (!this._events[name]) {
|
||||
this._events[name] = [];
|
||||
}
|
||||
this._events[name].push({ callback, context });
|
||||
return this;
|
||||
}
|
||||
|
||||
once(name: string, callback: (...args: unknown[]) => void, context?: unknown): this {
|
||||
const onceWrapper = (...args: unknown[]) => {
|
||||
this.off(name, onceWrapper);
|
||||
callback.apply(context, args);
|
||||
};
|
||||
return this.on(name, onceWrapper, context);
|
||||
}
|
||||
|
||||
off(name?: string, callback?: (...args: unknown[]) => void): this {
|
||||
if (!name) {
|
||||
this._events = {};
|
||||
return this;
|
||||
}
|
||||
if (!this._events[name]) return this;
|
||||
if (!callback) {
|
||||
delete this._events[name];
|
||||
return this;
|
||||
}
|
||||
this._events[name] = this._events[name].filter(
|
||||
listener => listener.callback !== callback
|
||||
);
|
||||
return this;
|
||||
}
|
||||
|
||||
removeAllListeners(): this {
|
||||
this._events = {};
|
||||
return this;
|
||||
}
|
||||
|
||||
emit(name: string, ...args: unknown[]): this {
|
||||
if (!this._events[name]) return this;
|
||||
this._events[name].forEach(listener => {
|
||||
listener.callback.apply(listener.context, args);
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
listenerCount(name: string): number {
|
||||
return this._events[name]?.length ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局事件管理器
|
||||
*/
|
||||
export class EventManager extends Emitter {
|
||||
private eventMap: Map<string, EventMeta>;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.eventMap = new Map();
|
||||
}
|
||||
|
||||
registerEvent(type: string, description: string): void {
|
||||
this.eventMap.set(type, { type, description, listeners: [] });
|
||||
}
|
||||
|
||||
getRegisteredEvents(): EventMeta[] {
|
||||
return Array.from(this.eventMap.values());
|
||||
}
|
||||
}
|
||||
|
||||
// 创建全局事件管理器实例
|
||||
export const eventManager = new EventManager();
|
||||
|
||||
// 注册标准事件类型(描述使用英文,避免编码问题)
|
||||
eventManager.registerEvent('load', 'resource load complete');
|
||||
eventManager.registerEvent('load-progress', 'resource load progress');
|
||||
eventManager.registerEvent('load-error', 'resource load error');
|
||||
eventManager.registerEvent('animation-start', 'animation start');
|
||||
eventManager.registerEvent('animation-end', 'animation end');
|
||||
eventManager.registerEvent('animation-loop', 'animation loop');
|
||||
eventManager.registerEvent('model-change', 'model change');
|
||||
eventManager.registerEvent('camera-change', 'camera change');
|
||||
eventManager.registerEvent('scene-ready', 'scene ready');
|
||||
eventManager.registerEvent('dispose', 'component disposed');
|
||||
|
||||
// 导出便捷函数
|
||||
export function on(eventName: string, callback: (...args: unknown[]) => void, context?: unknown): Emitter {
|
||||
return eventManager.on(eventName, callback, context);
|
||||
}
|
||||
|
||||
export function off(eventName?: string, callback?: (...args: unknown[]) => void): Emitter {
|
||||
return eventManager.off(eventName, callback);
|
||||
}
|
||||
|
||||
export function once(eventName: string, callback: (...args: unknown[]) => void, context?: unknown): Emitter {
|
||||
return eventManager.once(eventName, callback, context);
|
||||
}
|
||||
|
||||
export function emit(eventName: string, ...args: unknown[]): Emitter {
|
||||
return eventManager.emit(eventName, ...args);
|
||||
}
|
||||
|
||||
export function removeAllListeners(eventName?: string): Emitter {
|
||||
if (eventName) return eventManager.off(eventName);
|
||||
return eventManager.removeAllListeners();
|
||||
}
|
||||
15
tsconfig.json
Normal file
15
tsconfig.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowJs": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": false,
|
||||
"noEmit": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src/**/*", "index.html"]
|
||||
}
|
||||
37
vite.config.js
Normal file
37
vite.config.js
Normal file
@ -0,0 +1,37 @@
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': '/src'
|
||||
}
|
||||
},
|
||||
build: {
|
||||
lib: {
|
||||
entry: 'src/main.ts',
|
||||
name: 'kernel',
|
||||
formats: ['esm'],
|
||||
fileName: () => 'assets/index.js',
|
||||
},
|
||||
target: 'esnext',
|
||||
outDir: 'dist',
|
||||
assetsDir: 'assets',
|
||||
copyPublicDir: true,
|
||||
minify: false,
|
||||
reportCompressedSize: false,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
inlineDynamicImports: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: ['@ffmpeg/ffmpeg', '@ffmpeg/util']
|
||||
},
|
||||
server: {
|
||||
headers: {
|
||||
'Cross-Origin-Opener-Policy': 'same-origin',
|
||||
'Cross-Origin-Embedder-Policy': 'require-corp'
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user