@guoquan.net/flow-engine 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +86 -0
- package/dist/assets/avatars/default/config.json +8 -0
- package/dist/assets/avatars/expressive/config.json +37 -0
- package/dist/assets/avatars/expressive/model.glb +0 -0
- package/dist/assets/stages/default/config.json +11 -0
- package/dist/core/AnimationController.d.ts +31 -0
- package/dist/core/AvatarLoader.d.ts +29 -0
- package/dist/core/FlowEngine.d.ts +31 -0
- package/dist/core/StageLoader.d.ts +16 -0
- package/dist/flow.es.js +253 -0
- package/dist/flow.umd.js +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/types/index.d.ts +41 -0
- package/package.json +41 -0
package/README.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# Flow (服喽) 🌊
|
|
2
|
+
`@guoquan.net/flow-engine`
|
|
3
|
+
|
|
4
|
+
[](https://github.com/guoquan/flow-engine/actions/workflows/ci.yml)
|
|
5
|
+
[](https://codecov.io/gh/guoquan/flow-engine)
|
|
6
|
+
[](https://github.com/guoquan/flow-engine/actions/workflows/deploy.yml)
|
|
7
|
+
|
|
8
|
+
[English](#english) | [中文](#中文)
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## English
|
|
13
|
+
|
|
14
|
+
> **"Flow: Convincing at first breath."**
|
|
15
|
+
|
|
16
|
+
**Flow** (distributed as `@guoquan.net/flow-engine`) is a high-performance, lightweight web-based digital human engine. It empowers web applications with lifelike AI avatars through simple API-driven interactions.
|
|
17
|
+
|
|
18
|
+
### 🌟 Highlights
|
|
19
|
+
- **Modern Rendering**: Based on WebGPU for next-gen performance and visual quality.
|
|
20
|
+
- **Data-Driven**: Animation and behavior fully controlled via JSON configuration.
|
|
21
|
+
- **Zero-Dependency Core**: Pure frontend architecture, easy to integrate into any project.
|
|
22
|
+
|
|
23
|
+
### 🛠 Tech Stack
|
|
24
|
+
- **Core**: TypeScript
|
|
25
|
+
- **Rendering**: Three.js (WebGPU Renderer)
|
|
26
|
+
- **Build**: Vite
|
|
27
|
+
|
|
28
|
+
### 📦 Installation
|
|
29
|
+
|
|
30
|
+
You can install the SDK directly from GitHub:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
# Using npm
|
|
34
|
+
npm install github:guoquan/flow-engine
|
|
35
|
+
|
|
36
|
+
# Using pnpm
|
|
37
|
+
pnpm add github:guoquan/flow-engine
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
To use a specific version (recommended):
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
npm install github:guoquan/flow-engine#v0.1.0
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### 📚 Documentation
|
|
47
|
+
Please visit **[docs/](./docs/)** for the full documentation and API references.
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## 中文
|
|
52
|
+
|
|
53
|
+
> **"一开口,就服喽。"**
|
|
54
|
+
|
|
55
|
+
**Flow** (包名称:`@guoquan.net/flow-engine`) 是一个高性能、轻量级的 Web 端数字人引擎。它旨在通过简单的 API 驱动,为 Web 应用赋予栩栩如生的 AI 化身交互能力。
|
|
56
|
+
|
|
57
|
+
### 🌟 项目亮点
|
|
58
|
+
- **现代化渲染**:基于 WebGPU,提供下一代渲染性能与视觉效果。
|
|
59
|
+
- **数据驱动**:动画与行为完全通过 JSON 配置文件控制。
|
|
60
|
+
- **零依赖核心**:纯前端架构,无需后端即可运行,易于集成。
|
|
61
|
+
|
|
62
|
+
### 🛠 技术栈
|
|
63
|
+
- **核心**: TypeScript
|
|
64
|
+
- **渲染**: Three.js (WebGPU Renderer)
|
|
65
|
+
- **构建**: Vite
|
|
66
|
+
|
|
67
|
+
### 📦 安装
|
|
68
|
+
|
|
69
|
+
你可以直接从 GitHub 安装该 SDK:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
# 使用 npm
|
|
73
|
+
npm install github:guoquan/flow-engine
|
|
74
|
+
|
|
75
|
+
# 使用 pnpm
|
|
76
|
+
pnpm add github:guoquan/flow-engine
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
建议安装特定版本以保证稳定性:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
npm install github:guoquan/flow-engine#v0.1.0
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### 📚 文档索引
|
|
86
|
+
请访问 **[docs/](./docs/)** 查看完整文档与 API 说明。
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Robot Expressive",
|
|
3
|
+
"modelSrc": "model.glb",
|
|
4
|
+
"scale": 0.3,
|
|
5
|
+
"initialPosition": [0, 0, 0],
|
|
6
|
+
"animations": {
|
|
7
|
+
"defaultState": "idle",
|
|
8
|
+
"states": {
|
|
9
|
+
"idle": {
|
|
10
|
+
"clipName": "Idle",
|
|
11
|
+
"loop": true,
|
|
12
|
+
"timeScale": 0.4,
|
|
13
|
+
"fadeDuration": 1.0
|
|
14
|
+
},
|
|
15
|
+
"wave": {
|
|
16
|
+
"clipName": "Wave",
|
|
17
|
+
"loop": false,
|
|
18
|
+
"next": "idle",
|
|
19
|
+
"fadeDuration": 0.5
|
|
20
|
+
},
|
|
21
|
+
"dance": {
|
|
22
|
+
"clipName": "Dance",
|
|
23
|
+
"loop": false,
|
|
24
|
+
"next": "idle",
|
|
25
|
+
"timeScale": 1.2,
|
|
26
|
+
"fadeDuration": 0.5
|
|
27
|
+
},
|
|
28
|
+
"death": {
|
|
29
|
+
"clipName": "Death",
|
|
30
|
+
"loop": false,
|
|
31
|
+
"next": "idle",
|
|
32
|
+
"fadeDuration": 0.8,
|
|
33
|
+
"holdDuration": 3.0
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { AnimationStateConfig } from '../types';
|
|
2
|
+
import * as THREE from 'three';
|
|
3
|
+
export declare class AnimationController {
|
|
4
|
+
private mixer;
|
|
5
|
+
private clips;
|
|
6
|
+
private states;
|
|
7
|
+
private activeActions;
|
|
8
|
+
private currentState;
|
|
9
|
+
private defaultState;
|
|
10
|
+
constructor(model: THREE.Object3D, animations: THREE.AnimationClip[]);
|
|
11
|
+
/**
|
|
12
|
+
* Initialize with configuration
|
|
13
|
+
*/
|
|
14
|
+
init(config: {
|
|
15
|
+
defaultState: string;
|
|
16
|
+
states: Record<string, AnimationStateConfig>;
|
|
17
|
+
}): void;
|
|
18
|
+
/**
|
|
19
|
+
* Update mixer (call this every frame)
|
|
20
|
+
*/
|
|
21
|
+
update(delta: number): void;
|
|
22
|
+
/**
|
|
23
|
+
* Play a state
|
|
24
|
+
*/
|
|
25
|
+
play(stateName: string, forceReset?: boolean): void;
|
|
26
|
+
private onFinished;
|
|
27
|
+
/**
|
|
28
|
+
* Finds an animation clip by name using exact, case-insensitive, or fuzzy matching.
|
|
29
|
+
*/
|
|
30
|
+
private findClip;
|
|
31
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { AvatarConfig } from '../types';
|
|
2
|
+
import * as THREE from 'three';
|
|
3
|
+
/**
|
|
4
|
+
* AvatarLoader
|
|
5
|
+
* 负责加载数字人的配置和模型资源
|
|
6
|
+
*/
|
|
7
|
+
export declare class AvatarLoader {
|
|
8
|
+
private loader;
|
|
9
|
+
constructor();
|
|
10
|
+
/**
|
|
11
|
+
* 加载数字人
|
|
12
|
+
* @param configUrl 指向 config.json 的 URL
|
|
13
|
+
* @returns Promise<{ model: THREE.Object3D, config: AvatarConfig, animations: THREE.AnimationClip[] }>
|
|
14
|
+
*/
|
|
15
|
+
load(configUrl: string): Promise<{
|
|
16
|
+
model: THREE.Object3D;
|
|
17
|
+
config: AvatarConfig;
|
|
18
|
+
animations: THREE.AnimationClip[];
|
|
19
|
+
}>;
|
|
20
|
+
/**
|
|
21
|
+
* 应用配置到模型
|
|
22
|
+
*/
|
|
23
|
+
private applyConfig;
|
|
24
|
+
/**
|
|
25
|
+
* 创建一个占位符 Avatar (当模型文件缺失时)
|
|
26
|
+
* 生成一个简单的机器人形状
|
|
27
|
+
*/
|
|
28
|
+
private createFallbackAvatar;
|
|
29
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export declare class FlowEngine {
|
|
2
|
+
private container;
|
|
3
|
+
private scene;
|
|
4
|
+
private camera;
|
|
5
|
+
private renderer;
|
|
6
|
+
private controls;
|
|
7
|
+
private clock;
|
|
8
|
+
private loader;
|
|
9
|
+
private stageLoader;
|
|
10
|
+
private avatarModel;
|
|
11
|
+
private stageModel;
|
|
12
|
+
private animController;
|
|
13
|
+
private stageAnimController;
|
|
14
|
+
constructor(containerId: string);
|
|
15
|
+
private setupLights;
|
|
16
|
+
/**
|
|
17
|
+
* Load an avatar by config URL
|
|
18
|
+
*/
|
|
19
|
+
loadAvatar(configUrl: string): Promise<void>;
|
|
20
|
+
/**
|
|
21
|
+
* Load a stage (podium/scene) by config URL
|
|
22
|
+
*/
|
|
23
|
+
loadStage(configUrl: string): Promise<void>;
|
|
24
|
+
private onWindowResize;
|
|
25
|
+
isAutoRotate: boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Play a specific action
|
|
28
|
+
*/
|
|
29
|
+
playAction(action: string): void;
|
|
30
|
+
private animate;
|
|
31
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { StageConfig } from '../types';
|
|
2
|
+
import * as THREE from 'three';
|
|
3
|
+
export declare class StageLoader {
|
|
4
|
+
private loader;
|
|
5
|
+
constructor();
|
|
6
|
+
load(configUrl: string): Promise<{
|
|
7
|
+
model: THREE.Object3D;
|
|
8
|
+
config: StageConfig;
|
|
9
|
+
animations: THREE.AnimationClip[];
|
|
10
|
+
}>;
|
|
11
|
+
private applyConfig;
|
|
12
|
+
/**
|
|
13
|
+
* Generates a cool Sci-Fi Podium if no model is provided
|
|
14
|
+
*/
|
|
15
|
+
private createProceduralStage;
|
|
16
|
+
}
|
package/dist/flow.es.js
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import * as i from "three";
|
|
2
|
+
import { WebGPURenderer as g } from "three/webgpu";
|
|
3
|
+
import { OrbitControls as y } from "three/examples/jsm/controls/OrbitControls.js";
|
|
4
|
+
import { GLTFLoader as f } from "three/examples/jsm/loaders/GLTFLoader.js";
|
|
5
|
+
class M {
|
|
6
|
+
loader;
|
|
7
|
+
constructor() {
|
|
8
|
+
this.loader = new f();
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* 加载数字人
|
|
12
|
+
* @param configUrl 指向 config.json 的 URL
|
|
13
|
+
* @returns Promise<{ model: THREE.Object3D, config: AvatarConfig, animations: THREE.AnimationClip[] }>
|
|
14
|
+
*/
|
|
15
|
+
async load(t) {
|
|
16
|
+
console.log(`[Flow] Loading avatar config from: ${t}`);
|
|
17
|
+
try {
|
|
18
|
+
const e = await fetch(t);
|
|
19
|
+
if (!e.ok)
|
|
20
|
+
throw new Error(`Failed to load config: ${e.statusText}`);
|
|
21
|
+
const o = await e.json(), n = t.substring(0, t.lastIndexOf("/") + 1) + o.modelSrc;
|
|
22
|
+
console.log(`[Flow] Config loaded. Loading model from: ${n}`);
|
|
23
|
+
let r, s = [];
|
|
24
|
+
try {
|
|
25
|
+
const l = await this.loader.loadAsync(n);
|
|
26
|
+
r = l.scene, s = l.animations || [];
|
|
27
|
+
} catch (l) {
|
|
28
|
+
console.warn(`[Flow] Failed to load 3D model at ${n}. Using fallback placeholder.`, l), r = this.createFallbackAvatar();
|
|
29
|
+
}
|
|
30
|
+
return this.applyConfig(r, o), { model: r, config: o, animations: s };
|
|
31
|
+
} catch (e) {
|
|
32
|
+
throw console.error("[Flow] Error loading avatar:", e), e;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* 应用配置到模型
|
|
37
|
+
*/
|
|
38
|
+
applyConfig(t, e) {
|
|
39
|
+
e.scale && t.scale.setScalar(e.scale), e.initialPosition && t.position.set(...e.initialPosition), t.traverse((o) => {
|
|
40
|
+
o.isMesh && (o.castShadow = !0, o.receiveShadow = !0);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* 创建一个占位符 Avatar (当模型文件缺失时)
|
|
45
|
+
* 生成一个简单的机器人形状
|
|
46
|
+
*/
|
|
47
|
+
createFallbackAvatar() {
|
|
48
|
+
const t = new i.Group(), e = new i.MeshStandardMaterial({
|
|
49
|
+
color: 54015,
|
|
50
|
+
roughness: 0.3,
|
|
51
|
+
metalness: 0.8
|
|
52
|
+
}), o = new i.BoxGeometry(0.8, 0.9, 0.8), a = new i.Mesh(o, e);
|
|
53
|
+
a.position.y = 1.5, a.name = "Head", t.add(a);
|
|
54
|
+
const n = new i.SphereGeometry(0.1), r = new i.MeshBasicMaterial({ color: 16777215 }), s = new i.Mesh(n, r);
|
|
55
|
+
s.position.set(-0.2, 1.5, 0.4), t.add(s);
|
|
56
|
+
const l = new i.Mesh(n, r);
|
|
57
|
+
l.position.set(0.2, 1.5, 0.4), t.add(l);
|
|
58
|
+
const d = new i.CylinderGeometry(0.6, 0.4, 1.5, 8), c = new i.Mesh(d, e);
|
|
59
|
+
c.position.y = 0.5, c.name = "Body", t.add(c);
|
|
60
|
+
const h = new i.CapsuleGeometry(0.15, 1), p = new i.Mesh(h, e);
|
|
61
|
+
p.position.set(-0.9, 0.8, 0), p.rotation.z = Math.PI / 4, p.name = "LeftArm", t.add(p);
|
|
62
|
+
const w = new i.Mesh(h, e);
|
|
63
|
+
return w.position.set(0.9, 0.8, 0), w.rotation.z = -Math.PI / 4, w.name = "RightArm", t.add(w), t;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
class S {
|
|
67
|
+
loader;
|
|
68
|
+
constructor() {
|
|
69
|
+
this.loader = new f();
|
|
70
|
+
}
|
|
71
|
+
async load(t) {
|
|
72
|
+
try {
|
|
73
|
+
const e = await fetch(t);
|
|
74
|
+
if (!e.ok) throw new Error("Failed to load stage config");
|
|
75
|
+
const o = await e.json();
|
|
76
|
+
let a, n = [];
|
|
77
|
+
if (o.modelSrc) {
|
|
78
|
+
const s = t.substring(0, t.lastIndexOf("/") + 1) + o.modelSrc, l = await this.loader.loadAsync(s);
|
|
79
|
+
a = l.scene, n = l.animations || [];
|
|
80
|
+
} else
|
|
81
|
+
a = this.createProceduralStage();
|
|
82
|
+
return this.applyConfig(a, o), { model: a, config: o, animations: n };
|
|
83
|
+
} catch (e) {
|
|
84
|
+
throw console.error("[Flow] Stage load error:", e), e;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
applyConfig(t, e) {
|
|
88
|
+
e.scale && t.scale.setScalar(e.scale), e.position && t.position.set(...e.position), e.rotation && t.rotation.set(...e.rotation), t.traverse((o) => {
|
|
89
|
+
o.isMesh && (o.receiveShadow = !0, o.castShadow = !0);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Generates a cool Sci-Fi Podium if no model is provided
|
|
94
|
+
*/
|
|
95
|
+
createProceduralStage() {
|
|
96
|
+
const t = new i.Group(), e = new i.CylinderGeometry(2, 2.2, 0.2, 32), o = new i.MeshStandardMaterial({ color: 2236962, roughness: 0.2, metalness: 0.8 }), a = new i.Mesh(e, o);
|
|
97
|
+
a.position.y = -0.1, t.add(a);
|
|
98
|
+
const n = new i.TorusGeometry(1.8, 0.05, 16, 100), r = new i.MeshBasicMaterial({ color: 54015 }), s = new i.Mesh(n, r);
|
|
99
|
+
s.rotation.x = -Math.PI / 2, s.position.y = 0.01, t.add(s);
|
|
100
|
+
const l = new i.BoxGeometry(0.5, 0.05, 0.5);
|
|
101
|
+
for (let d = 0; d < 4; d++) {
|
|
102
|
+
const c = new i.Mesh(l, o), h = d / 4 * Math.PI * 2;
|
|
103
|
+
c.position.set(Math.cos(h) * 2.5, 0, Math.sin(h) * 2.5), c.lookAt(0, 0, 0), t.add(c);
|
|
104
|
+
}
|
|
105
|
+
return t;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
class u {
|
|
109
|
+
mixer;
|
|
110
|
+
clips;
|
|
111
|
+
states = {};
|
|
112
|
+
activeActions = /* @__PURE__ */ new Map();
|
|
113
|
+
currentState = null;
|
|
114
|
+
defaultState = "idle";
|
|
115
|
+
constructor(t, e) {
|
|
116
|
+
this.mixer = new i.AnimationMixer(t), this.clips = e, this.mixer.addEventListener("finished", this.onFinished.bind(this));
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Initialize with configuration
|
|
120
|
+
*/
|
|
121
|
+
init(t) {
|
|
122
|
+
this.defaultState = t.defaultState, this.states = t.states, Object.entries(this.states).forEach(([e, o]) => {
|
|
123
|
+
this.findClip(o.clipName) || console.warn(`[AnimationController] Clip "${o.clipName}" for state "${e}" not found.`);
|
|
124
|
+
}), this.play(this.defaultState);
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Update mixer (call this every frame)
|
|
128
|
+
*/
|
|
129
|
+
update(t) {
|
|
130
|
+
this.mixer.update(t);
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Play a state
|
|
134
|
+
*/
|
|
135
|
+
play(t, e = !1) {
|
|
136
|
+
const o = this.states[t];
|
|
137
|
+
if (!o) {
|
|
138
|
+
console.warn(`[AnimationController] State "${t}" not defined.`);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const a = this.findClip(o.clipName);
|
|
142
|
+
if (!a || this.currentState === t && !e) return;
|
|
143
|
+
const n = this.mixer.clipAction(a);
|
|
144
|
+
n.setLoop(o.loop ? i.LoopRepeat : i.LoopOnce, o.loop ? 1 / 0 : 1), n.clampWhenFinished = !o.loop, n.timeScale = o.timeScale ?? 1;
|
|
145
|
+
const r = o.fadeDuration ?? 0.3;
|
|
146
|
+
if (this.currentState) {
|
|
147
|
+
const s = this.activeActions.get(this.currentState);
|
|
148
|
+
s && s !== n ? (s.fadeOut(r), n.reset(), n.fadeIn(r), n.play()) : n.reset().play();
|
|
149
|
+
} else
|
|
150
|
+
n.reset().play();
|
|
151
|
+
this.activeActions.set(t, n), this.currentState = t, console.log(`[Anim] Transition to: ${t} (Loop: ${o.loop})`);
|
|
152
|
+
}
|
|
153
|
+
onFinished(t) {
|
|
154
|
+
if (this.currentState && this.activeActions.get(this.currentState) === t.action) {
|
|
155
|
+
const e = this.states[this.currentState], o = () => {
|
|
156
|
+
e && e.next ? this.play(e.next) : !e.loop && this.currentState !== this.defaultState && this.play(this.defaultState);
|
|
157
|
+
};
|
|
158
|
+
e.holdDuration && e.holdDuration > 0 ? setTimeout(o, e.holdDuration * 1e3) : o();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Finds an animation clip by name using exact, case-insensitive, or fuzzy matching.
|
|
163
|
+
*/
|
|
164
|
+
findClip(t) {
|
|
165
|
+
let e = this.clips.find((o) => o.name === t);
|
|
166
|
+
return e || (e = this.clips.find((o) => o.name.toLowerCase() === t.toLowerCase())), e || (e = this.clips.find((o) => o.name.toLowerCase().includes(t.toLowerCase()))), e;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
class L {
|
|
170
|
+
container;
|
|
171
|
+
scene;
|
|
172
|
+
camera;
|
|
173
|
+
renderer;
|
|
174
|
+
controls;
|
|
175
|
+
clock;
|
|
176
|
+
loader;
|
|
177
|
+
stageLoader;
|
|
178
|
+
avatarModel = null;
|
|
179
|
+
stageModel = null;
|
|
180
|
+
animController = null;
|
|
181
|
+
stageAnimController = null;
|
|
182
|
+
constructor(t) {
|
|
183
|
+
const e = document.getElementById(t);
|
|
184
|
+
if (!e) throw new Error(`Container #${t} not found`);
|
|
185
|
+
this.container = e, this.clock = new i.Clock(), this.loader = new M(), this.stageLoader = new S(), this.scene = new i.Scene(), this.scene.background = new i.Color(1710618), this.scene.fog = new i.Fog(1710618, 10, 50), this.camera = new i.PerspectiveCamera(
|
|
186
|
+
45,
|
|
187
|
+
window.innerWidth / window.innerHeight,
|
|
188
|
+
0.1,
|
|
189
|
+
100
|
|
190
|
+
), this.camera.position.set(0, 1.5, 5), this.renderer = new g({ antialias: !0, alpha: !0 }), this.renderer.setSize(window.innerWidth, window.innerHeight), this.renderer.setPixelRatio(window.devicePixelRatio), this.container.appendChild(this.renderer.domElement), this.setupLights(), this.controls = new y(this.camera, this.renderer.domElement), this.controls.enableDamping = !0, this.controls.target.set(0, 1, 0), window.addEventListener("resize", this.onWindowResize.bind(this)), this.renderer.setAnimationLoop(this.animate.bind(this));
|
|
191
|
+
}
|
|
192
|
+
setupLights() {
|
|
193
|
+
const t = new i.AmbientLight(16777215, 0.6);
|
|
194
|
+
this.scene.add(t);
|
|
195
|
+
const e = new i.DirectionalLight(16777215, 1);
|
|
196
|
+
e.position.set(5, 10, 7), e.castShadow = !0, this.scene.add(e);
|
|
197
|
+
const o = new i.SpotLight(54015, 5);
|
|
198
|
+
o.position.set(-5, 5, -5), o.lookAt(0, 1, 0), this.scene.add(o);
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Load an avatar by config URL
|
|
202
|
+
*/
|
|
203
|
+
async loadAvatar(t) {
|
|
204
|
+
this.avatarModel && this.scene.remove(this.avatarModel);
|
|
205
|
+
const { model: e, config: o, animations: a } = await this.loader.load(t);
|
|
206
|
+
if (this.avatarModel = e, this.scene.add(this.avatarModel), a.length > 0) {
|
|
207
|
+
this.animController = new u(this.avatarModel, a);
|
|
208
|
+
const n = o.animations || {
|
|
209
|
+
defaultState: "idle",
|
|
210
|
+
states: {
|
|
211
|
+
idle: { clipName: "Idle", loop: !0 },
|
|
212
|
+
wave: { clipName: "Wave", loop: !1, next: "idle" },
|
|
213
|
+
dance: { clipName: "Dance", loop: !1, next: "idle" },
|
|
214
|
+
bow: { clipName: "Bow", loop: !1, next: "idle" },
|
|
215
|
+
walk: { clipName: "Walking", loop: !0 }
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
this.animController.init(n);
|
|
219
|
+
}
|
|
220
|
+
console.log(`[Flow] Avatar "${o.name}" loaded successfully.`);
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Load a stage (podium/scene) by config URL
|
|
224
|
+
*/
|
|
225
|
+
async loadStage(t) {
|
|
226
|
+
this.stageModel && this.scene.remove(this.stageModel);
|
|
227
|
+
const { model: e, config: o, animations: a } = await this.stageLoader.load(t);
|
|
228
|
+
this.stageModel = e, this.scene.add(this.stageModel), a.length > 0 && o.animations && (this.stageAnimController = new u(this.stageModel, a), this.stageAnimController.init(o.animations)), console.log(`[Flow] Stage "${o.name}" loaded successfully.`);
|
|
229
|
+
}
|
|
230
|
+
onWindowResize() {
|
|
231
|
+
this.camera.aspect = window.innerWidth / window.innerHeight, this.camera.updateProjectionMatrix(), this.renderer.setSize(window.innerWidth, window.innerHeight);
|
|
232
|
+
}
|
|
233
|
+
// State
|
|
234
|
+
isAutoRotate = !1;
|
|
235
|
+
/**
|
|
236
|
+
* Play a specific action
|
|
237
|
+
*/
|
|
238
|
+
playAction(t) {
|
|
239
|
+
if (console.log(`[FlowEngine] Playing action: ${t}`), this.animController) {
|
|
240
|
+
this.animController.play(t.toLowerCase());
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
animate(t) {
|
|
245
|
+
const e = this.clock.getDelta();
|
|
246
|
+
this.avatarModel && this.animController && this.animController.update(e), this.stageModel && this.stageAnimController && this.stageAnimController.update(e), this.controls.autoRotate = this.isAutoRotate, this.controls.update(), this.renderer.render(this.scene, this.camera);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
export {
|
|
250
|
+
u as AnimationController,
|
|
251
|
+
M as AvatarLoader,
|
|
252
|
+
L as FlowEngine
|
|
253
|
+
};
|
package/dist/flow.umd.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
(function(l,p){typeof exports=="object"&&typeof module<"u"?p(exports,require("three"),require("three/webgpu"),require("three/examples/jsm/controls/OrbitControls.js"),require("three/examples/jsm/loaders/GLTFLoader.js")):typeof define=="function"&&define.amd?define(["exports","three","three/webgpu","three/examples/jsm/controls/OrbitControls.js","three/examples/jsm/loaders/GLTFLoader.js"],p):(l=typeof globalThis<"u"?globalThis:l||self,p(l.Flow={},l.THREE,l.THREE,l.OrbitControls_js,l.GLTFLoader_js))})(this,(function(l,p,S,C,y){"use strict";function A(d){const t=Object.create(null,{[Symbol.toStringTag]:{value:"Module"}});if(d){for(const e in d)if(e!=="default"){const o=Object.getOwnPropertyDescriptor(d,e);Object.defineProperty(t,e,o.get?o:{enumerable:!0,get:()=>d[e]})}}return t.default=d,Object.freeze(t)}const i=A(p);class M{loader;constructor(){this.loader=new y.GLTFLoader}async load(t){console.log(`[Flow] Loading avatar config from: ${t}`);try{const e=await fetch(t);if(!e.ok)throw new Error(`Failed to load config: ${e.statusText}`);const o=await e.json(),n=t.substring(0,t.lastIndexOf("/")+1)+o.modelSrc;console.log(`[Flow] Config loaded. Loading model from: ${n}`);let r,a=[];try{const c=await this.loader.loadAsync(n);r=c.scene,a=c.animations||[]}catch(c){console.warn(`[Flow] Failed to load 3D model at ${n}. Using fallback placeholder.`,c),r=this.createFallbackAvatar()}return this.applyConfig(r,o),{model:r,config:o,animations:a}}catch(e){throw console.error("[Flow] Error loading avatar:",e),e}}applyConfig(t,e){e.scale&&t.scale.setScalar(e.scale),e.initialPosition&&t.position.set(...e.initialPosition),t.traverse(o=>{o.isMesh&&(o.castShadow=!0,o.receiveShadow=!0)})}createFallbackAvatar(){const t=new i.Group,e=new i.MeshStandardMaterial({color:54015,roughness:.3,metalness:.8}),o=new i.BoxGeometry(.8,.9,.8),s=new i.Mesh(o,e);s.position.y=1.5,s.name="Head",t.add(s);const n=new i.SphereGeometry(.1),r=new i.MeshBasicMaterial({color:16777215}),a=new i.Mesh(n,r);a.position.set(-.2,1.5,.4),t.add(a);const c=new i.Mesh(n,r);c.position.set(.2,1.5,.4),t.add(c);const w=new i.CylinderGeometry(.6,.4,1.5,8),h=new i.Mesh(w,e);h.position.y=.5,h.name="Body",t.add(h);const m=new i.CapsuleGeometry(.15,1),u=new i.Mesh(m,e);u.position.set(-.9,.8,0),u.rotation.z=Math.PI/4,u.name="LeftArm",t.add(u);const f=new i.Mesh(m,e);return f.position.set(.9,.8,0),f.rotation.z=-Math.PI/4,f.name="RightArm",t.add(f),t}}class L{loader;constructor(){this.loader=new y.GLTFLoader}async load(t){try{const e=await fetch(t);if(!e.ok)throw new Error("Failed to load stage config");const o=await e.json();let s,n=[];if(o.modelSrc){const a=t.substring(0,t.lastIndexOf("/")+1)+o.modelSrc,c=await this.loader.loadAsync(a);s=c.scene,n=c.animations||[]}else s=this.createProceduralStage();return this.applyConfig(s,o),{model:s,config:o,animations:n}}catch(e){throw console.error("[Flow] Stage load error:",e),e}}applyConfig(t,e){e.scale&&t.scale.setScalar(e.scale),e.position&&t.position.set(...e.position),e.rotation&&t.rotation.set(...e.rotation),t.traverse(o=>{o.isMesh&&(o.receiveShadow=!0,o.castShadow=!0)})}createProceduralStage(){const t=new i.Group,e=new i.CylinderGeometry(2,2.2,.2,32),o=new i.MeshStandardMaterial({color:2236962,roughness:.2,metalness:.8}),s=new i.Mesh(e,o);s.position.y=-.1,t.add(s);const n=new i.TorusGeometry(1.8,.05,16,100),r=new i.MeshBasicMaterial({color:54015}),a=new i.Mesh(n,r);a.rotation.x=-Math.PI/2,a.position.y=.01,t.add(a);const c=new i.BoxGeometry(.5,.05,.5);for(let w=0;w<4;w++){const h=new i.Mesh(c,o),m=w/4*Math.PI*2;h.position.set(Math.cos(m)*2.5,0,Math.sin(m)*2.5),h.lookAt(0,0,0),t.add(h)}return t}}class g{mixer;clips;states={};activeActions=new Map;currentState=null;defaultState="idle";constructor(t,e){this.mixer=new i.AnimationMixer(t),this.clips=e,this.mixer.addEventListener("finished",this.onFinished.bind(this))}init(t){this.defaultState=t.defaultState,this.states=t.states,Object.entries(this.states).forEach(([e,o])=>{this.findClip(o.clipName)||console.warn(`[AnimationController] Clip "${o.clipName}" for state "${e}" not found.`)}),this.play(this.defaultState)}update(t){this.mixer.update(t)}play(t,e=!1){const o=this.states[t];if(!o){console.warn(`[AnimationController] State "${t}" not defined.`);return}const s=this.findClip(o.clipName);if(!s||this.currentState===t&&!e)return;const n=this.mixer.clipAction(s);n.setLoop(o.loop?i.LoopRepeat:i.LoopOnce,o.loop?1/0:1),n.clampWhenFinished=!o.loop,n.timeScale=o.timeScale??1;const r=o.fadeDuration??.3;if(this.currentState){const a=this.activeActions.get(this.currentState);a&&a!==n?(a.fadeOut(r),n.reset(),n.fadeIn(r),n.play()):n.reset().play()}else n.reset().play();this.activeActions.set(t,n),this.currentState=t,console.log(`[Anim] Transition to: ${t} (Loop: ${o.loop})`)}onFinished(t){if(this.currentState&&this.activeActions.get(this.currentState)===t.action){const e=this.states[this.currentState],o=()=>{e&&e.next?this.play(e.next):!e.loop&&this.currentState!==this.defaultState&&this.play(this.defaultState)};e.holdDuration&&e.holdDuration>0?setTimeout(o,e.holdDuration*1e3):o()}}findClip(t){let e=this.clips.find(o=>o.name===t);return e||(e=this.clips.find(o=>o.name.toLowerCase()===t.toLowerCase())),e||(e=this.clips.find(o=>o.name.toLowerCase().includes(t.toLowerCase()))),e}}class b{container;scene;camera;renderer;controls;clock;loader;stageLoader;avatarModel=null;stageModel=null;animController=null;stageAnimController=null;constructor(t){const e=document.getElementById(t);if(!e)throw new Error(`Container #${t} not found`);this.container=e,this.clock=new i.Clock,this.loader=new M,this.stageLoader=new L,this.scene=new i.Scene,this.scene.background=new i.Color(1710618),this.scene.fog=new i.Fog(1710618,10,50),this.camera=new i.PerspectiveCamera(45,window.innerWidth/window.innerHeight,.1,100),this.camera.position.set(0,1.5,5),this.renderer=new S.WebGPURenderer({antialias:!0,alpha:!0}),this.renderer.setSize(window.innerWidth,window.innerHeight),this.renderer.setPixelRatio(window.devicePixelRatio),this.container.appendChild(this.renderer.domElement),this.setupLights(),this.controls=new C.OrbitControls(this.camera,this.renderer.domElement),this.controls.enableDamping=!0,this.controls.target.set(0,1,0),window.addEventListener("resize",this.onWindowResize.bind(this)),this.renderer.setAnimationLoop(this.animate.bind(this))}setupLights(){const t=new i.AmbientLight(16777215,.6);this.scene.add(t);const e=new i.DirectionalLight(16777215,1);e.position.set(5,10,7),e.castShadow=!0,this.scene.add(e);const o=new i.SpotLight(54015,5);o.position.set(-5,5,-5),o.lookAt(0,1,0),this.scene.add(o)}async loadAvatar(t){this.avatarModel&&this.scene.remove(this.avatarModel);const{model:e,config:o,animations:s}=await this.loader.load(t);if(this.avatarModel=e,this.scene.add(this.avatarModel),s.length>0){this.animController=new g(this.avatarModel,s);const n=o.animations||{defaultState:"idle",states:{idle:{clipName:"Idle",loop:!0},wave:{clipName:"Wave",loop:!1,next:"idle"},dance:{clipName:"Dance",loop:!1,next:"idle"},bow:{clipName:"Bow",loop:!1,next:"idle"},walk:{clipName:"Walking",loop:!0}}};this.animController.init(n)}console.log(`[Flow] Avatar "${o.name}" loaded successfully.`)}async loadStage(t){this.stageModel&&this.scene.remove(this.stageModel);const{model:e,config:o,animations:s}=await this.stageLoader.load(t);this.stageModel=e,this.scene.add(this.stageModel),s.length>0&&o.animations&&(this.stageAnimController=new g(this.stageModel,s),this.stageAnimController.init(o.animations)),console.log(`[Flow] Stage "${o.name}" loaded successfully.`)}onWindowResize(){this.camera.aspect=window.innerWidth/window.innerHeight,this.camera.updateProjectionMatrix(),this.renderer.setSize(window.innerWidth,window.innerHeight)}isAutoRotate=!1;playAction(t){if(console.log(`[FlowEngine] Playing action: ${t}`),this.animController){this.animController.play(t.toLowerCase());return}}animate(t){const e=this.clock.getDelta();this.avatarModel&&this.animController&&this.animController.update(e),this.stageModel&&this.stageAnimController&&this.stageAnimController.update(e),this.controls.autoRotate=this.isAutoRotate,this.controls.update(),this.renderer.render(this.scene,this.camera)}}l.AnimationController=g,l.AvatarLoader=M,l.FlowEngine=b,Object.defineProperty(l,Symbol.toStringTag,{value:"Module"})}));
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Avatar Configuration Interface
|
|
3
|
+
* 定义数字人的元数据结构,对应资源包中的 config.json
|
|
4
|
+
*/
|
|
5
|
+
export interface AvatarConfig {
|
|
6
|
+
name: string;
|
|
7
|
+
modelSrc: string;
|
|
8
|
+
scale?: number;
|
|
9
|
+
initialPosition?: [number, number, number];
|
|
10
|
+
/** Animation State Machine Configuration */
|
|
11
|
+
animations?: {
|
|
12
|
+
defaultState: string;
|
|
13
|
+
states: Record<string, AnimationStateConfig>;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export interface AnimationStateConfig {
|
|
17
|
+
/** The exact name of the clip in the GLB (fuzzy matching supported if not found) */
|
|
18
|
+
clipName: string;
|
|
19
|
+
/** Whether the animation should loop */
|
|
20
|
+
loop?: boolean;
|
|
21
|
+
/** The state to transition to after this one finishes (for non-looping) */
|
|
22
|
+
next?: string;
|
|
23
|
+
/** Cross-fade duration in seconds (default: 0.3) */
|
|
24
|
+
fadeDuration?: number;
|
|
25
|
+
/** Time scale (speed), default 1.0 */
|
|
26
|
+
timeScale?: number;
|
|
27
|
+
/** Duration to hold the last frame before transitioning (seconds) */
|
|
28
|
+
holdDuration?: number;
|
|
29
|
+
}
|
|
30
|
+
export interface StageConfig {
|
|
31
|
+
name: string;
|
|
32
|
+
modelSrc?: string;
|
|
33
|
+
scale?: number;
|
|
34
|
+
position?: [number, number, number];
|
|
35
|
+
rotation?: [number, number, number];
|
|
36
|
+
/** Animation logic for the stage (e.g. rotating, rising) */
|
|
37
|
+
animations?: {
|
|
38
|
+
defaultState: string;
|
|
39
|
+
states: Record<string, AnimationStateConfig>;
|
|
40
|
+
};
|
|
41
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@guoquan.net/flow-engine",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "A high-performance, WebGPU-based digital human engine.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/flow.umd.js",
|
|
7
|
+
"module": "./dist/flow.es.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public"
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"dev": "vite",
|
|
17
|
+
"build": "tsc && vite build",
|
|
18
|
+
"build:demo": "tsc && cross-env BUILD_DEMO=true vite build",
|
|
19
|
+
"test": "vitest run",
|
|
20
|
+
"test:watch": "vitest",
|
|
21
|
+
"test:coverage": "vitest run --coverage",
|
|
22
|
+
"preview": "vite preview",
|
|
23
|
+
"prepare": "npm run build"
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"three": ">=0.170.0"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@codecov/vite-plugin": "^1.9.1",
|
|
30
|
+
"@types/node": "^25.0.3",
|
|
31
|
+
"@types/three": "^0.182.0",
|
|
32
|
+
"@vitest/coverage-v8": "^4.0.16",
|
|
33
|
+
"cross-env": "^10.1.0",
|
|
34
|
+
"jsdom": "^27.4.0",
|
|
35
|
+
"three": "^0.182.0",
|
|
36
|
+
"typescript": "~5.9.3",
|
|
37
|
+
"vite": "^7.2.4",
|
|
38
|
+
"vite-plugin-dts": "^4.5.4",
|
|
39
|
+
"vitest": "^4.0.16"
|
|
40
|
+
}
|
|
41
|
+
}
|