@starcried/starlite-vrm 0.1.0-alpha.2 → 0.1.0-alpha.3

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 CHANGED
@@ -39,6 +39,24 @@ const controller = await mountModelWidget(host, {
39
39
 
40
40
  ## 进阶
41
41
 
42
+ `adjustments` 为当前模型提供可选的姿态与跟随校准,例如:
43
+
44
+ ```js
45
+ controller.setModelAdjustments({
46
+ idle: { armLiftDegrees: 10, palmTwistDegrees: 5, wristLiftDegrees: 8, handCurl: 0.2, elbowBendDegrees: 8 },
47
+ gaze: { headStrength: 0.8, eyeStrength: 1, smoothingMs: 120 },
48
+ });
49
+ ```
50
+
51
+ 相同对象也可作为挂载配置的 `adjustments`。该方法整体替换当前调节,`{}` 恢复原样;实时调节不重新加载模型、不重启动作。双臂抬高范围为 −30~45°,掌心扭转和手腕抬高均为 −45~45°,肘部弯曲为 −20~45°;掌心调节左右对称;手腕抬高在前臂弯曲平面调整手部方向,正值向上折,不移动腕部位置,不改变上臂、前臂或掌心扭转的方向约定。手部张合为 0~1,省略时保留原动作,0 张开、1 握拳,仅处理已映射的标准手指。姿态调节只作用于程序内置 Idle,不改模型自带动作或外部 VRMA。头眼跟随强度各为 0~2,默认 1;0 停用对应跟随,仍尊重现有限角和前景动作方向。平滑时间为 20~1000 ms,省略时沿用 `gaze.smoothingMs`。模型缺少眼球绑定时,调节不能生成眼球跟随。
52
+
53
+ `0.1.0-alpha.3` 提供 `performance: {show: true}`(默认关闭),也可用
54
+ `controller.setPerformanceVisible?.(true)` 即时切换。含义和测量边界见
55
+ [性能预算](https://github.com/StarCried/starlite/blob/main/docs/performance.md)。
56
+ Node 侧 `@starcried/starlite-vrm/inspect` 返回 `capabilities` 与逻辑 `metrics`;
57
+ `VrmInspectionError.code/details` 提供受控原因及缺失骨骼名,不回显模型路径或外部 URI。
58
+ 见[兼容说明](https://github.com/StarCried/starlite/blob/main/docs/model-compatibility.md)。
59
+
42
60
  [动作、句库、相机与性能配置](https://github.com/StarCried/starlite/blob/main/docs/configuration.md) · [构建博客资源](https://github.com/StarCried/starlite/blob/main/docs/examples/prepare-vrm.cjs) · [Hexo 插件](https://github.com/StarCried/starlite/blob/main/packages/hexo/README.md) · [开发与验证](https://github.com/StarCried/starlite/blob/main/docs/development.md)
43
61
 
44
62
  配置 CSP 的网站须为模型内嵌贴图允许适用的 `img-src blob:` 和 `connect-src blob:`。独立网页若要在手机上避免加载运行时,须在检查屏宽后动态导入;上例的静态导入会下载 JS。
@@ -0,0 +1,14 @@
1
+ import { AnimationClip, type Object3D } from "three";
2
+ import type { VRMHumanoid, VRMPose } from "@pixiv/three-vrm";
3
+ import type { ModelAdjustments } from "./types.js";
4
+ type Idle = NonNullable<ModelAdjustments["idle"]>;
5
+ /** A controller-owned derivative of the program Idle; authored clips never enter here. */
6
+ export declare class AdjustableIdle {
7
+ #private;
8
+ readonly clip: AnimationClip;
9
+ constructor(source: AnimationClip, root: Object3D, humanoid: Pick<VRMHumanoid, "getNormalizedBoneNode">, metaVersion?: "0" | "1");
10
+ update(idle?: Idle): void;
11
+ /** Refresh stopped/paused neutral without sampling or resetting the live mixer. */
12
+ neutralPose(pose: VRMPose): VRMPose;
13
+ }
14
+ export {};
@@ -0,0 +1,106 @@
1
+ import { AnimationClip, MathUtils, PropertyBinding, Quaternion, Vector3 } from "three";
2
+ /** A controller-owned derivative of the program Idle; authored clips never enter here. */
3
+ export class AdjustableIdle {
4
+ clip;
5
+ #targets = [];
6
+ #basis;
7
+ constructor(source, root, humanoid, metaVersion = "1") {
8
+ // Match createVRMAnimationClip: VRM0 faces -Z, so its normalized rig's
9
+ // X/Z rotation axes are the opposite of VRM1's character-space axes.
10
+ this.#basis = metaVersion === "0" ? -1 : 1;
11
+ const bones = new Map();
12
+ for (const [side, sign] of [["left", 1], ["right", -1]]) {
13
+ const add = (suffix, kind, joint) => {
14
+ const bone = (side + suffix), node = humanoid.getNormalizedBoneNode(bone);
15
+ if (node)
16
+ bones.set(node, { bone, side: sign, kind, ...(joint ? { joint } : {}) });
17
+ };
18
+ add("UpperArm", "arm");
19
+ add("LowerArm", "elbow");
20
+ add("Hand", "palm");
21
+ for (const finger of ["Index", "Middle", "Ring", "Little"]) {
22
+ for (const joint of ["Proximal", "Intermediate", "Distal"])
23
+ add(finger + joint, "finger", joint);
24
+ }
25
+ for (const joint of ["Metacarpal", "Proximal", "Distal"])
26
+ add("Thumb" + joint, "thumb", joint);
27
+ }
28
+ const tracks = source.tracks.map(track => {
29
+ const parsed = PropertyBinding.parseTrackName(track.name);
30
+ const node = PropertyBinding.findNode(root, parsed.nodeName);
31
+ const bone = node ? bones.get(node) : undefined;
32
+ if (!bone || parsed.propertyName !== "quaternion" || track.getValueSize() !== 4 || track.ValueTypeName !== "quaternion")
33
+ return track;
34
+ const copy = track.clone();
35
+ this.#targets.push({ ...bone, source: track, track: copy });
36
+ return copy;
37
+ });
38
+ this.clip = new AnimationClip(source.name, source.duration, tracks, source.blendMode);
39
+ }
40
+ update(idle = {}) {
41
+ const rotation = new Quaternion(), correction = new Quaternion(), wrist = new Quaternion(), motion = new Quaternion(), initial = new Quaternion(), axis = new Vector3();
42
+ for (const target of this.#targets) {
43
+ const { source, track, side, kind } = target;
44
+ track.values.set(source.values);
45
+ const degrees = kind === "arm" ? idle.armLiftDegrees ?? 0
46
+ : kind === "palm" ? idle.palmTwistDegrees ?? 0 : kind === "elbow" ? idle.elbowBendDegrees ?? 0 : 0;
47
+ const hand = kind === "finger" || kind === "thumb";
48
+ const wristDegrees = kind === "palm" ? idle.wristLiftDegrees ?? 0 : 0;
49
+ if ((!hand && !degrees && !wristDegrees) || (hand && idle.handCurl === undefined))
50
+ continue;
51
+ if (hand) {
52
+ initial.fromArray(source.values, 0).invert();
53
+ // The four fingers curl towards the palm. The thumb has its own
54
+ // opposition and flexion recipe rather than sharing their bend axis.
55
+ const curl = idle.handCurl;
56
+ if (kind === "finger") {
57
+ const bend = target.joint === "Proximal" ? 70 : target.joint === "Intermediate" ? 90 : 60;
58
+ correction.setFromAxisAngle(axis.set(0, 0, -side * this.#basis), MathUtils.degToRad(bend * curl));
59
+ }
60
+ else if (target.joint === "Metacarpal") {
61
+ correction.setFromAxisAngle(axis.set(0, side, 0), MathUtils.degToRad(35 * curl));
62
+ correction.multiply(rotation.setFromAxisAngle(axis.set(0, 0, -side * this.#basis), MathUtils.degToRad(15 * curl)));
63
+ }
64
+ else {
65
+ correction.setFromAxisAngle(axis.set(0, 0, -side * this.#basis), MathUtils.degToRad((target.joint === "Proximal" ? 45 : 65) * curl));
66
+ }
67
+ }
68
+ else {
69
+ // A rotation axis is an axial vector: mirroring left/right across X
70
+ // keeps its X sign and reverses Y/Z. Opposite palm-X signs would
71
+ // turn one palm inward and the other outward.
72
+ axis.set(kind === "palm" ? this.#basis : 0, kind === "elbow" ? -side : 0, kind === "arm" ? side * this.#basis : 0);
73
+ correction.setFromAxisAngle(axis, MathUtils.degToRad(degrees));
74
+ if (kind === "palm")
75
+ wrist.setFromAxisAngle(axis.set(0, 0, side * this.#basis), MathUtils.degToRad(wristDegrees));
76
+ }
77
+ for (let at = 0; at < source.values.length; at += 4) {
78
+ rotation.fromArray(source.values, at);
79
+ if (hand) {
80
+ // Keep the source motion around its first sample, including tiny
81
+ // breathing changes, while replacing only the static finger pose.
82
+ motion.copy(initial).multiply(rotation);
83
+ rotation.copy(correction).multiply(motion);
84
+ }
85
+ else if (kind === "palm") {
86
+ // Bend in the forearm's frame before applying the palm-local twist,
87
+ // so changing palm direction does not rotate the wrist-lift axis.
88
+ rotation.premultiply(wrist).multiply(correction);
89
+ }
90
+ else if (kind === "arm")
91
+ rotation.premultiply(correction);
92
+ else
93
+ rotation.multiply(correction);
94
+ rotation.normalize().toArray(track.values, at);
95
+ }
96
+ }
97
+ }
98
+ /** Refresh stopped/paused neutral without sampling or resetting the live mixer. */
99
+ neutralPose(pose) {
100
+ const result = { ...pose };
101
+ for (const { bone, track } of this.#targets) {
102
+ result[bone] = { ...result[bone], rotation: Array.from(track.values.slice(0, 4)) };
103
+ }
104
+ return result;
105
+ }
106
+ }
package/dist/config.cjs CHANGED
@@ -20,6 +20,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  var config_exports = {};
21
21
  __export(config_exports, {
22
22
  validateCharacterProfile: () => validateCharacterProfile,
23
+ validateModelAdjustments: () => validateModelAdjustments,
23
24
  validateVrmWidgetConfig: () => validateVrmWidgetConfig
24
25
  });
25
26
  module.exports = __toCommonJS(config_exports);
@@ -61,12 +62,40 @@ function resolveDialogueSentences(input, animations, field) {
61
62
  return { ...lines ? { lines } : {}, linesByAnimation };
62
63
  }
63
64
 
65
+ // ../core/dist/performance.js
66
+ function validatePerformanceConfig(value) {
67
+ if (value === void 0)
68
+ return;
69
+ if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).some((key) => key !== "show") || "show" in value && value.show !== void 0 && typeof value.show !== "boolean") {
70
+ throw new Error("performance must be an object with an optional boolean show");
71
+ }
72
+ }
73
+
64
74
  // src/config.ts
65
75
  function finite(value, min, max) {
66
76
  return typeof value === "number" && Number.isFinite(value) && value >= min && value <= max;
67
77
  }
78
+ function validateModelAdjustments(input) {
79
+ const object = (value) => Boolean(value && typeof value === "object" && !Array.isArray(value));
80
+ if (!object(input) || Object.keys(input).some((key) => key !== "idle" && key !== "gaze")) throw new Error("invalid model adjustments");
81
+ const groups = {
82
+ idle: { armLiftDegrees: [-30, 45], palmTwistDegrees: [-45, 45], wristLiftDegrees: [-45, 45], handCurl: [0, 1], elbowBendDegrees: [-20, 45] },
83
+ gaze: { headStrength: [0, 2], eyeStrength: [0, 2], smoothingMs: [20, 1e3] }
84
+ };
85
+ for (const [group, limits] of Object.entries(groups)) {
86
+ const values = input[group];
87
+ if (values === void 0) continue;
88
+ if (!object(values)) throw new Error(`invalid model adjustments.${group}`);
89
+ for (const [key, value] of Object.entries(values)) {
90
+ const range = Object.hasOwn(limits, key) ? limits[key] : void 0;
91
+ if (!range || value !== void 0 && !finite(value, range[0], range[1])) throw new Error(`invalid model adjustments.${group}.${key}`);
92
+ }
93
+ }
94
+ }
68
95
  function validateVrmWidgetConfig(config) {
69
96
  if (!config || config.backend !== void 0 && config.backend !== "vrm") throw new Error("invalid VRM backend");
97
+ if (config.adjustments !== void 0) validateModelAdjustments(config.adjustments);
98
+ validatePerformanceConfig(config.performance);
70
99
  if (config.interactions?.passThrough !== void 0 && typeof config.interactions.passThrough !== "boolean") throw new Error("interactions.passThrough must be boolean");
71
100
  if (config.interactions?.passThrough && config.interactions.drag) throw new Error("passThrough and drag cannot both be enabled");
72
101
  for (const key of ["model", "fallback"]) if (typeof config[key] !== "string" || !config[key].trim()) throw new Error(`${key} is required`);
@@ -148,5 +177,6 @@ function validateCharacterProfile(input) {
148
177
  // Annotate the CommonJS export names for ESM import in node:
149
178
  0 && (module.exports = {
150
179
  validateCharacterProfile,
180
+ validateModelAdjustments,
151
181
  validateVrmWidgetConfig
152
182
  });
package/dist/config.d.ts CHANGED
@@ -1,3 +1,4 @@
1
- import type { CharacterProfile, VrmWidgetConfig } from "./types.js";
1
+ import type { CharacterProfile, ModelAdjustments, VrmWidgetConfig } from "./types.js";
2
+ export declare function validateModelAdjustments(input: unknown): asserts input is ModelAdjustments;
2
3
  export declare function validateVrmWidgetConfig(config: VrmWidgetConfig): void;
3
4
  export declare function validateCharacterProfile(input: unknown): asserts input is CharacterProfile;
package/dist/config.js CHANGED
@@ -1,10 +1,34 @@
1
- import { resolveDialogueSentences } from "@starcried/starlite-core";
1
+ import { resolveDialogueSentences, validatePerformanceConfig } from "@starcried/starlite-core";
2
2
  function finite(value, min, max) {
3
3
  return typeof value === "number" && Number.isFinite(value) && value >= min && value <= max;
4
4
  }
5
+ export function validateModelAdjustments(input) {
6
+ const object = (value) => Boolean(value && typeof value === "object" && !Array.isArray(value));
7
+ if (!object(input) || Object.keys(input).some(key => key !== "idle" && key !== "gaze"))
8
+ throw new Error("invalid model adjustments");
9
+ const groups = {
10
+ idle: { armLiftDegrees: [-30, 45], palmTwistDegrees: [-45, 45], wristLiftDegrees: [-45, 45], handCurl: [0, 1], elbowBendDegrees: [-20, 45] },
11
+ gaze: { headStrength: [0, 2], eyeStrength: [0, 2], smoothingMs: [20, 1000] },
12
+ };
13
+ for (const [group, limits] of Object.entries(groups)) {
14
+ const values = input[group];
15
+ if (values === undefined)
16
+ continue;
17
+ if (!object(values))
18
+ throw new Error(`invalid model adjustments.${group}`);
19
+ for (const [key, value] of Object.entries(values)) {
20
+ const range = Object.hasOwn(limits, key) ? limits[key] : undefined;
21
+ if (!range || (value !== undefined && !finite(value, range[0], range[1])))
22
+ throw new Error(`invalid model adjustments.${group}.${key}`);
23
+ }
24
+ }
25
+ }
5
26
  export function validateVrmWidgetConfig(config) {
6
27
  if (!config || (config.backend !== undefined && config.backend !== "vrm"))
7
28
  throw new Error("invalid VRM backend");
29
+ if (config.adjustments !== undefined)
30
+ validateModelAdjustments(config.adjustments);
31
+ validatePerformanceConfig(config.performance);
8
32
  if (config.interactions?.passThrough !== undefined && typeof config.interactions.passThrough !== "boolean")
9
33
  throw new Error("interactions.passThrough must be boolean");
10
34
  if (config.interactions?.passThrough && config.interactions.drag)
package/dist/gaze.d.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  import { type Object3D, type PerspectiveCamera } from "three";
2
2
  import type { VRMLookAt } from "@pixiv/three-vrm";
3
- import type { VrmWidgetConfig } from "./types.js";
3
+ import type { ModelAdjustments, VrmWidgetConfig } from "./types.js";
4
4
  /** Pointer projection and angle limits are independent of avatar scale and VRM version. */
5
5
  export declare class PointerGaze {
6
6
  #private;
7
7
  readonly limits: NonNullable<VrmWidgetConfig["gaze"]>;
8
8
  constructor(limits?: NonNullable<VrmWidgetConfig["gaze"]>);
9
+ setAdjustments(adjustments?: ModelAdjustments["gaze"]): void;
9
10
  move(x: number, y: number, rect: {
10
11
  left: number;
11
12
  top: number;
package/dist/gaze.js CHANGED
@@ -18,9 +18,11 @@ export class PointerGaze {
18
18
  #tracking = false;
19
19
  #yaw = 0;
20
20
  #pitch = 0;
21
+ #adjustments = {};
21
22
  constructor(limits = {}) {
22
23
  this.limits = limits;
23
24
  }
25
+ setAdjustments(adjustments = {}) { this.#adjustments = { ...adjustments }; }
24
26
  move(x, y, rect, viewport = { width: rect.left + rect.width, height: rect.top + rect.height }) {
25
27
  if (![x, y, rect.left, rect.top, rect.width, rect.height, viewport.width, viewport.height].every(Number.isFinite) || rect.width <= 0 || rect.height <= 0 || viewport.width <= 0 || viewport.height <= 0)
26
28
  return;
@@ -51,16 +53,17 @@ export class PointerGaze {
51
53
  /** Normalized humanoid correction precedes the single humanoid update. */
52
54
  updateHead(head, camera, delta, authored) {
53
55
  this.restoreHead();
54
- if (!head || authored) {
56
+ if (!head || authored || this.#adjustments.headStrength === 0) {
55
57
  this.#headYaw = 0;
56
58
  this.#headPitch = 0;
57
59
  return;
58
60
  }
59
61
  head.getWorldPosition(this.#head);
60
62
  const offset = this.#offset(camera, this.#head);
61
- const blend = 1 - Math.exp(-Math.max(0, delta) / ((this.limits.smoothingMs ?? 100) / 1000));
62
- this.#headYaw += (offset.x * (this.limits.maxYawDegrees ?? 25) * .6 - this.#headYaw) * blend;
63
- this.#headPitch += (offset.y * (this.limits.maxPitchDegrees ?? 15) * .6 - this.#headPitch) * blend;
63
+ const blend = 1 - Math.exp(-Math.max(0, delta) / ((this.#adjustments.smoothingMs ?? this.limits.smoothingMs ?? 100) / 1000));
64
+ const yawLimit = this.limits.maxYawDegrees ?? 25, pitchLimit = this.limits.maxPitchDegrees ?? 15, strength = this.#adjustments.headStrength ?? 1;
65
+ this.#headYaw += (MathUtils.clamp(offset.x * yawLimit * .6 * strength, -yawLimit, yawLimit) - this.#headYaw) * blend;
66
+ this.#headPitch += (MathUtils.clamp(offset.y * pitchLimit * .6 * strength, -pitchLimit, pitchLimit) - this.#headPitch) * blend;
64
67
  this.#headBase.copy(head.quaternion);
65
68
  this.#posedHead = head;
66
69
  this.#rotation.setFromAxisAngle(this.#scratch.setFromMatrixColumn(camera.matrixWorld, 1), MathUtils.degToRad(this.#headYaw));
@@ -80,12 +83,20 @@ export class PointerGaze {
80
83
  this.#pitch = lookAt.pitch;
81
84
  return;
82
85
  }
86
+ if (this.#adjustments.eyeStrength === 0) {
87
+ this.#yaw = 0;
88
+ this.#pitch = 0;
89
+ lookAt.yaw = 0;
90
+ lookAt.pitch = 0;
91
+ return;
92
+ }
83
93
  lookAt.humanoid.getRawBoneNode("head")?.updateWorldMatrix(true, false);
84
94
  lookAt.getLookAtWorldPosition(this.#head);
85
95
  const yawLimit = this.limits.maxYawDegrees ?? 25, pitchLimit = this.limits.maxPitchDegrees ?? 15;
86
96
  this.#target.copy(camera.position);
87
97
  if (this.#tracking) {
88
- const { x, y } = this.#offset(camera, this.#head);
98
+ const offset = this.#offset(camera, this.#head), strength = this.#adjustments.eyeStrength ?? 1;
99
+ const x = MathUtils.clamp(offset.x * strength, -1, 1), y = MathUtils.clamp(offset.y * strength, -1, 1);
89
100
  const distance = camera.position.distanceTo(this.#head);
90
101
  this.#target.addScaledVector(this.#scratch.setFromMatrixColumn(camera.matrixWorld, 0), distance * Math.tan(MathUtils.degToRad(x * yawLimit)));
91
102
  this.#target.addScaledVector(this.#scratch.setFromMatrixColumn(camera.matrixWorld, 1), distance / Math.cos(MathUtils.degToRad(x * yawLimit)) * Math.tan(MathUtils.degToRad(y * pitchLimit)));
@@ -93,7 +104,7 @@ export class PointerGaze {
93
104
  lookAt.lookAt(this.#target);
94
105
  const yaw = Math.max(-yawLimit, Math.min(yawLimit, lookAt.yaw));
95
106
  const pitch = Math.max(-pitchLimit, Math.min(pitchLimit, lookAt.pitch));
96
- const blend = 1 - Math.exp(-Math.max(0, delta) / ((this.limits.smoothingMs ?? 100) / 1000));
107
+ const blend = 1 - Math.exp(-Math.max(0, delta) / ((this.#adjustments.smoothingMs ?? this.limits.smoothingMs ?? 100) / 1000));
97
108
  this.#yaw += (yaw - this.#yaw) * blend;
98
109
  this.#pitch += (pitch - this.#pitch) * blend;
99
110
  lookAt.yaw = Math.max(-yawLimit, Math.min(yawLimit, this.#yaw));
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { type MountOptions } from "@starcried/starlite-core";
2
2
  import type { VrmController, VrmWidgetConfig } from "./types.js";
3
3
  export type * from "./types.js";
4
- export { validateVrmWidgetConfig } from "./config.js";
4
+ export { QuickSettingsMenu, type QuickSettingsOptions, type QuickSettingItem } from "@starcried/starlite-core";
5
+ export { validateModelAdjustments, validateVrmWidgetConfig } from "./config.js";
5
6
  export declare function mountModelWidget(host: HTMLElement, config: VrmWidgetConfig, options?: MountOptions): Promise<VrmController>;
6
7
  export declare const mountVrmWidget: typeof mountModelWidget;