@starcried/starlite-vrm 0.0.0-stage → 0.1.0-alpha.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.
@@ -0,0 +1,2 @@
1
+ /** Approved six-second motion; embedded in the runtime, with no asset request. */
2
+ export declare function defaultIdleBytes(): ArrayBuffer;
@@ -0,0 +1,5 @@
1
+ import { defaultIdleBase64 } from "./default-idle-data.js";
2
+ /** Approved six-second motion; embedded in the runtime, with no asset request. */
3
+ export function defaultIdleBytes() {
4
+ return Uint8Array.from(atob(defaultIdleBase64), value => value.charCodeAt(0)).buffer;
5
+ }
@@ -0,0 +1,11 @@
1
+ import { AnimationClip } from "three";
2
+ import type { GLTF } from "three/addons/loaders/GLTFLoader.js";
3
+ import type { VRM } from "@pixiv/three-vrm";
4
+ type Json = Record<string, any>;
5
+ /** Capture model-local to normalized rig calibration before rotateVRM0 or applying a pose. */
6
+ export declare function prepareEmbeddedAnimations(gltf: GLTF, vrm: VRM, json: Json, signal?: AbortSignal): Promise<{
7
+ clips: AnimationClip[];
8
+ eyeClips: ReadonlySet<string>;
9
+ applyMorphs(): void;
10
+ }>;
11
+ export {};
@@ -0,0 +1,154 @@
1
+ import { AnimationClip, InterpolateDiscrete, InterpolateLinear, Interpolant, Matrix3, Matrix4, NumberKeyframeTrack, Object3D, PropertyBinding, Quaternion, Vector3 } from "three";
2
+ import { embeddedAnimationCatalog } from "./inspect.js";
3
+ /** Capture model-local to normalized rig calibration before rotateVRM0 or applying a pose. */
4
+ export async function prepareEmbeddedAnimations(gltf, vrm, json, signal) {
5
+ const catalog = embeddedAnimationCatalog(json);
6
+ const rawBones = new Map();
7
+ for (const [name, raw] of Object.entries(vrm.humanoid.rawHumanBones ?? {})) {
8
+ if (!raw)
9
+ continue;
10
+ const normalized = vrm.humanoid.normalizedHumanBones[name]?.node;
11
+ if (!normalized)
12
+ continue;
13
+ raw.node.updateWorldMatrix(true, false);
14
+ normalized.updateWorldMatrix(true, false);
15
+ const parentMatrix = raw.node.parent?.matrixWorld ?? new Matrix4();
16
+ const normalizedParent = normalized.parent?.matrixWorld ?? new Matrix4();
17
+ const parent = new Quaternion();
18
+ parentMatrix.decompose(new Vector3(), parent, new Vector3());
19
+ rawBones.set(raw.node, { normalized, rest: raw.node.quaternion.clone(), parent, translation: normalizedParent.clone().invert().multiply(parentMatrix), hips: name === "hips", eye: name === "leftEye" || name === "rightEye" });
20
+ }
21
+ const proxies = new Map();
22
+ const registered = new Map();
23
+ for (const expression of vrm.expressionManager?.expressions ?? []) {
24
+ for (const bind of expression.binds) {
25
+ const morph = bind;
26
+ if (!morph.primitives || morph.index === undefined)
27
+ continue;
28
+ for (const mesh of morph.primitives) {
29
+ const indices = registered.get(mesh) ?? new Set();
30
+ indices.add(morph.index);
31
+ registered.set(mesh, indices);
32
+ }
33
+ }
34
+ }
35
+ const clips = [];
36
+ const eyeClips = new Set();
37
+ for (const [clipIndex, entry] of catalog.entries()) {
38
+ if (clipIndex > 0)
39
+ await new Promise(resolve => setTimeout(resolve, 0));
40
+ signal?.throwIfAborted();
41
+ const source = gltf.animations[clipIndex];
42
+ if (!source)
43
+ throw new Error("embedded animation did not load");
44
+ const animation = json.animations[clipIndex];
45
+ const expressions = animation.extras?.starlite?.expressions;
46
+ if (expressions !== undefined && (!expressions || typeof expressions !== "object" || Array.isArray(expressions) || Object.keys(expressions).length > 64))
47
+ throw new Error("invalid embedded expression tracks");
48
+ const tracks = [];
49
+ for (const sourceTrack of source.tracks) {
50
+ const parsed = PropertyBinding.parseTrackName(sourceTrack.name);
51
+ const node = PropertyBinding.findNode(gltf.scene, parsed.nodeName);
52
+ if (!node)
53
+ throw new Error("embedded animation targets a missing node");
54
+ const raw = rawBones.get(node);
55
+ if (raw && parsed.propertyName === "quaternion") {
56
+ if (raw.eye)
57
+ eyeClips.add(entry.name);
58
+ const track = sourceTrack.clone();
59
+ track.name = raw.normalized.uuid + ".quaternion";
60
+ const before = raw.parent, after = raw.rest.clone().invert().multiply(raw.parent.clone().invert());
61
+ const q = new Quaternion();
62
+ for (let offset = 0; offset < track.values.length; offset += 4)
63
+ q.fromArray(track.values, offset).premultiply(before).multiply(after).toArray(track.values, offset);
64
+ tracks.push(track);
65
+ }
66
+ else if (raw?.hips && parsed.propertyName === "position") {
67
+ const track = sourceTrack.clone();
68
+ track.name = raw.normalized.uuid + ".position";
69
+ const cubic = isCubic(track), derivative = new Matrix3().setFromMatrix4(raw.translation), vector = new Vector3();
70
+ for (let offset = 0; offset < track.values.length; offset += 3) {
71
+ vector.fromArray(track.values, offset);
72
+ if (cubic && Math.floor(offset / 3) % 3 !== 1)
73
+ vector.applyMatrix3(derivative);
74
+ else
75
+ vector.applyMatrix4(raw.translation);
76
+ vector.toArray(track.values, offset);
77
+ }
78
+ tracks.push(track);
79
+ }
80
+ else if (parsed.propertyName === "morphTargetInfluences") {
81
+ // Named samplers preserve expression-driven blink/ear recipes; standard
82
+ // mesh weights remain in authored files for other glTF viewers.
83
+ if (expressions !== undefined)
84
+ continue;
85
+ const mesh = node;
86
+ if (!mesh.morphTargetInfluences)
87
+ throw new Error("embedded animation has invalid morph targets");
88
+ let proxy = proxies.get(mesh);
89
+ if (!proxy) {
90
+ const proxyNode = new Object3D();
91
+ const indices = registered.get(mesh) ?? new Set();
92
+ proxyNode.weights = mesh.morphTargetInfluences.map((value, index) => indices.has(index) ? 0 : value);
93
+ gltf.scene.add(proxyNode);
94
+ proxy = { node: proxyNode, mesh, registered: indices };
95
+ proxies.set(mesh, proxy);
96
+ }
97
+ const cubic = isCubic(sourceTrack), width = sourceTrack.getValueSize() / (cubic ? 3 : 1);
98
+ if (width !== mesh.morphTargetInfluences.length)
99
+ throw new Error("embedded morph track size does not match mesh");
100
+ for (let index = 0; index < width; index++) {
101
+ const track = new NumberKeyframeTrack(proxy.node.uuid + ".weights[" + index + "]", sourceTrack.times.slice(), sourceTrack.values.filter((_, offset) => offset % width === index));
102
+ track.createInterpolant = sourceTrack.createInterpolant;
103
+ tracks.push(track);
104
+ }
105
+ }
106
+ else {
107
+ const track = sourceTrack.clone();
108
+ track.name = node.uuid + "." + parsed.propertyName + (parsed.propertyIndex !== undefined ? "[" + parsed.propertyIndex + "]" : "");
109
+ tracks.push(track);
110
+ }
111
+ }
112
+ if (expressions !== undefined) {
113
+ for (const [name, samplerIndex] of Object.entries(expressions)) {
114
+ const expression = vrm.expressionManager?.getExpression(name);
115
+ const sampler = Number.isSafeInteger(samplerIndex) ? animation.samplers[samplerIndex] : undefined;
116
+ if (!expression || !sampler || json.accessors[sampler.output]?.type !== "SCALAR" || json.accessors[sampler.output]?.componentType !== 5126)
117
+ throw new Error("invalid embedded expression sampler");
118
+ const times = await gltf.parser.getDependency("accessor", sampler.input);
119
+ const values = await gltf.parser.getDependency("accessor", sampler.output);
120
+ const cubic = sampler.interpolation === "CUBICSPLINE";
121
+ if (times.itemSize !== 1 || values.itemSize !== 1 || values.count !== times.count * (cubic ? 3 : 1))
122
+ throw new Error("invalid embedded expression sampler size");
123
+ signal?.throwIfAborted();
124
+ const target = /^VRMExpression_blink(?:Left|Right)?$/.test(expression.name) ? expression.name : expression.uuid;
125
+ const track = new NumberKeyframeTrack(target + ".weight", Array.from({ length: times.count }, (_, index) => times.getX(index)), Array.from({ length: values.count }, (_, index) => values.getX(index)), sampler.interpolation === "STEP" ? InterpolateDiscrete : InterpolateLinear);
126
+ if (cubic)
127
+ track.createInterpolant = scalarCubicFactory;
128
+ tracks.push(track);
129
+ }
130
+ }
131
+ if (!tracks.length || tracks.some(track => Array.from(track.values).some(value => !Number.isFinite(value))))
132
+ throw new Error("embedded animation has invalid tracks");
133
+ clips.push(new AnimationClip(entry.name, entry.durationMs / 1000, tracks));
134
+ }
135
+ return {
136
+ clips, eyeClips,
137
+ applyMorphs() {
138
+ for (const { node, mesh, registered: indices } of proxies.values())
139
+ for (const [index, value] of node.weights.entries())
140
+ mesh.morphTargetInfluences[index] = indices.has(index) ? Math.min(1, mesh.morphTargetInfluences[index] + value) : value;
141
+ },
142
+ };
143
+ }
144
+ function isCubic(track) { return Boolean(track.createInterpolant.isInterpolantFactoryMethodGLTFCubicSpline); }
145
+ class ScalarCubicInterpolant extends Interpolant {
146
+ copySampleValue_(index) { this.resultBuffer[0] = this.sampleValues[index * 3 + 1]; return this.resultBuffer; }
147
+ interpolate_(index, start, time, end) {
148
+ const duration = end - start, t = (time - start) / duration, t2 = t * t, t3 = t2 * t;
149
+ const left = (index - 1) * 3, right = index * 3, values = this.sampleValues;
150
+ this.resultBuffer[0] = (2 * t3 - 3 * t2 + 1) * values[left + 1] + (t3 - 2 * t2 + t) * duration * values[left + 2] + (-2 * t3 + 3 * t2) * values[right + 1] + (t3 - t2) * duration * values[right];
151
+ return this.resultBuffer;
152
+ }
153
+ }
154
+ const scalarCubicFactory = Object.assign(function (result) { return new ScalarCubicInterpolant(this.times, this.values, 1, result); }, { isInterpolantFactoryMethodGLTFCubicSpline: true });
package/dist/gaze.d.ts ADDED
@@ -0,0 +1,28 @@
1
+ import { type Object3D, type PerspectiveCamera } from "three";
2
+ import type { VRMLookAt } from "@pixiv/three-vrm";
3
+ import type { VrmWidgetConfig } from "./types.js";
4
+ /** Pointer projection and angle limits are independent of avatar scale and VRM version. */
5
+ export declare class PointerGaze {
6
+ #private;
7
+ readonly limits: NonNullable<VrmWidgetConfig["gaze"]>;
8
+ constructor(limits?: NonNullable<VrmWidgetConfig["gaze"]>);
9
+ move(x: number, y: number, rect: {
10
+ left: number;
11
+ top: number;
12
+ width: number;
13
+ height: number;
14
+ }, viewport?: {
15
+ width: number;
16
+ height: number;
17
+ }): void;
18
+ reset(): void;
19
+ /** Remove the last additive correction before the animation mixer samples. */
20
+ restoreHead(): void;
21
+ get headAngles(): {
22
+ yawDegrees: number;
23
+ pitchDegrees: number;
24
+ };
25
+ /** Normalized humanoid correction precedes the single humanoid update. */
26
+ updateHead(head: Object3D | null, camera: PerspectiveCamera, delta: number, authored: boolean): void;
27
+ update(lookAt: VRMLookAt, camera: PerspectiveCamera, delta: number, authored: boolean): void;
28
+ }
package/dist/gaze.js ADDED
@@ -0,0 +1,102 @@
1
+ import { Vector2, Vector3, Quaternion, MathUtils } from "three";
2
+ /** Pointer projection and angle limits are independent of avatar scale and VRM version. */
3
+ export class PointerGaze {
4
+ limits;
5
+ #pointer = new Vector2();
6
+ #head = new Vector3();
7
+ #target = new Vector3();
8
+ #scratch = new Vector3();
9
+ #headBase = new Quaternion();
10
+ #parent = new Quaternion();
11
+ #rotation = new Quaternion();
12
+ #pitchRotation = new Quaternion();
13
+ #posedHead;
14
+ #headYaw = 0;
15
+ #headPitch = 0;
16
+ #rect = { left: 0, top: 0, width: 1, height: 1 };
17
+ #viewport = { width: 1, height: 1 };
18
+ #tracking = false;
19
+ #yaw = 0;
20
+ #pitch = 0;
21
+ constructor(limits = {}) {
22
+ this.limits = limits;
23
+ }
24
+ move(x, y, rect, viewport = { width: rect.left + rect.width, height: rect.top + rect.height }) {
25
+ 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
+ return;
27
+ this.#pointer.set(x, y);
28
+ this.#rect = rect;
29
+ this.#viewport = viewport;
30
+ this.#tracking = true;
31
+ }
32
+ reset() { this.#tracking = false; }
33
+ /** Remove the last additive correction before the animation mixer samples. */
34
+ restoreHead() {
35
+ if (this.#posedHead)
36
+ this.#posedHead.quaternion.copy(this.#headBase);
37
+ this.#posedHead = undefined;
38
+ }
39
+ get headAngles() {
40
+ return { yawDegrees: this.#headYaw, pitchDegrees: this.#headPitch };
41
+ }
42
+ #offset(camera, position) {
43
+ if (!this.#tracking)
44
+ return new Vector2();
45
+ const projected = this.#scratch.copy(position).project(camera);
46
+ const eyeX = this.#rect.left + (projected.x + 1) * this.#rect.width / 2;
47
+ const eyeY = this.#rect.top + (1 - projected.y) * this.#rect.height / 2;
48
+ const dx = this.#pointer.x - eyeX, dy = eyeY - this.#pointer.y;
49
+ return new Vector2(MathUtils.clamp(dx / Math.max(this.#rect.width / 2, dx < 0 ? eyeX : this.#viewport.width - eyeX), -1, 1), MathUtils.clamp(dy / Math.max(this.#rect.height / 2, dy > 0 ? eyeY : this.#viewport.height - eyeY), -1, 1));
50
+ }
51
+ /** Normalized humanoid correction precedes the single humanoid update. */
52
+ updateHead(head, camera, delta, authored) {
53
+ this.restoreHead();
54
+ if (!head || authored) {
55
+ this.#headYaw = 0;
56
+ this.#headPitch = 0;
57
+ return;
58
+ }
59
+ head.getWorldPosition(this.#head);
60
+ 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;
64
+ this.#headBase.copy(head.quaternion);
65
+ this.#posedHead = head;
66
+ this.#rotation.setFromAxisAngle(this.#scratch.setFromMatrixColumn(camera.matrixWorld, 1), MathUtils.degToRad(this.#headYaw));
67
+ this.#pitchRotation.setFromAxisAngle(this.#scratch.setFromMatrixColumn(camera.matrixWorld, 0), -MathUtils.degToRad(this.#headPitch));
68
+ this.#rotation.multiply(this.#pitchRotation);
69
+ if (head.parent)
70
+ head.parent.getWorldQuaternion(this.#parent);
71
+ else
72
+ this.#parent.identity();
73
+ this.#rotation.premultiply(this.#parent.clone().invert()).multiply(this.#parent);
74
+ head.quaternion.premultiply(this.#rotation);
75
+ }
76
+ update(lookAt, camera, delta, authored) {
77
+ lookAt.autoUpdate = false;
78
+ if (authored) {
79
+ this.#yaw = lookAt.yaw;
80
+ this.#pitch = lookAt.pitch;
81
+ return;
82
+ }
83
+ lookAt.humanoid.getRawBoneNode("head")?.updateWorldMatrix(true, false);
84
+ lookAt.getLookAtWorldPosition(this.#head);
85
+ const yawLimit = this.limits.maxYawDegrees ?? 25, pitchLimit = this.limits.maxPitchDegrees ?? 15;
86
+ this.#target.copy(camera.position);
87
+ if (this.#tracking) {
88
+ const { x, y } = this.#offset(camera, this.#head);
89
+ const distance = camera.position.distanceTo(this.#head);
90
+ this.#target.addScaledVector(this.#scratch.setFromMatrixColumn(camera.matrixWorld, 0), distance * Math.tan(MathUtils.degToRad(x * yawLimit)));
91
+ this.#target.addScaledVector(this.#scratch.setFromMatrixColumn(camera.matrixWorld, 1), distance / Math.cos(MathUtils.degToRad(x * yawLimit)) * Math.tan(MathUtils.degToRad(y * pitchLimit)));
92
+ }
93
+ lookAt.lookAt(this.#target);
94
+ const yaw = Math.max(-yawLimit, Math.min(yawLimit, lookAt.yaw));
95
+ 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));
97
+ this.#yaw += (yaw - this.#yaw) * blend;
98
+ this.#pitch += (pitch - this.#pitch) * blend;
99
+ lookAt.yaw = Math.max(-yawLimit, Math.min(yawLimit, this.#yaw));
100
+ lookAt.pitch = Math.max(-pitchLimit, Math.min(pitchLimit, this.#pitch));
101
+ }
102
+ }
@@ -0,0 +1,14 @@
1
+ import { Object3D, type PerspectiveCamera } from "three";
2
+ import type { VRMSpringBoneManager } from "@pixiv/three-vrm";
3
+ /** Window movement exists outside the canvas. Feed an equivalent bounded world
4
+ * translation to the actual VRM solver, then remove it before drawing. */
5
+ export declare class HostMotion {
6
+ #private;
7
+ readonly root: Object3D;
8
+ readonly manager?: VRMSpringBoneManager | undefined;
9
+ constructor(root: Object3D, manager?: VRMSpringBoneManager | undefined);
10
+ setPosition(x: number, y: number): void;
11
+ suspend(): void;
12
+ update(delta: number, camera: PerspectiveCamera, height: number, beforePhysics?: () => void): void;
13
+ destroy(): void;
14
+ }
@@ -0,0 +1,97 @@
1
+ import { MathUtils, Object3D, Vector3 } from "three";
2
+ /** Window movement exists outside the canvas. Feed an equivalent bounded world
3
+ * translation to the actual VRM solver, then remove it before drawing. */
4
+ export class HostMotion {
5
+ root;
6
+ manager;
7
+ #last;
8
+ #pending = new Vector3();
9
+ #offset = new Vector3();
10
+ #position = new Vector3();
11
+ #movement = new Vector3();
12
+ #up = new Vector3();
13
+ #centers = new Map();
14
+ #originals = new Map();
15
+ constructor(root, manager) {
16
+ this.root = root;
17
+ this.manager = manager;
18
+ root.updateMatrixWorld(true);
19
+ for (const joint of [...(manager?.joints ?? [])]) {
20
+ if (!joint.center)
21
+ continue;
22
+ const original = joint.center;
23
+ let proxy = this.#centers.get(original);
24
+ if (!proxy) {
25
+ proxy = new Object3D();
26
+ proxy.matrixAutoUpdate = false;
27
+ proxy.matrix.copy(original.matrixWorld);
28
+ proxy.updateMatrixWorld(true);
29
+ this.#centers.set(original, proxy);
30
+ }
31
+ this.#originals.set(joint, original);
32
+ // Public remove/add invalidates the manager's dependency ordering.
33
+ manager.deleteJoint(joint);
34
+ joint.center = proxy;
35
+ manager.addJoint(joint);
36
+ }
37
+ if (this.#originals.size)
38
+ manager?.reset();
39
+ }
40
+ setPosition(x, y) {
41
+ if (![x, y].every(Number.isFinite) || Math.max(Math.abs(x), Math.abs(y)) > 65536)
42
+ throw new Error("invalid host position");
43
+ if (this.#last) {
44
+ this.#pending.x += x - this.#last[0];
45
+ this.#pending.y += y - this.#last[1];
46
+ }
47
+ this.#last = [x, y];
48
+ }
49
+ suspend() { this.#last = undefined; this.#pending.set(0, 0, 0); }
50
+ update(delta, camera, height, beforePhysics) {
51
+ if (!this.manager) {
52
+ beforePhysics?.();
53
+ return;
54
+ }
55
+ if (delta > 0) {
56
+ const depth = Math.max(.1, camera.position.distanceTo(this.root.position));
57
+ const metresPerPixel = 2 * depth * Math.tan(MathUtils.degToRad(camera.fov / 2)) / Math.max(1, height);
58
+ this.#movement.setFromMatrixColumn(camera.matrixWorld, 0).multiplyScalar(this.#pending.x * metresPerPixel * .35);
59
+ this.#up.setFromMatrixColumn(camera.matrixWorld, 1).multiplyScalar(-this.#pending.y * metresPerPixel * .35);
60
+ this.#movement.add(this.#up).clampLength(0, .08);
61
+ this.#pending.set(0, 0, 0);
62
+ this.#offset.add(this.#movement);
63
+ }
64
+ this.#position.copy(this.root.position);
65
+ this.root.position.add(this.#offset);
66
+ this.root.updateMatrixWorld(true);
67
+ for (const [original, proxy] of this.#centers) {
68
+ proxy.matrix.copy(original.matrixWorld);
69
+ proxy.matrix.elements[12] -= this.#offset.x;
70
+ proxy.matrix.elements[13] -= this.#offset.y;
71
+ proxy.matrix.elements[14] -= this.#offset.z;
72
+ proxy.updateMatrixWorld(true);
73
+ }
74
+ // A rest-pose capture must see the same translated world as the solver's
75
+ // retained tails. Drawing-space captures after a drag would reapply the
76
+ // whole host offset on every expression change, flipping the ears.
77
+ // This also applies at delta zero: pose sampling can still refresh a rest.
78
+ try {
79
+ beforePhysics?.();
80
+ this.manager.update(delta);
81
+ }
82
+ finally {
83
+ this.root.position.copy(this.#position);
84
+ this.root.updateMatrixWorld(true);
85
+ }
86
+ }
87
+ destroy() {
88
+ for (const [joint, original] of this.#originals) {
89
+ this.manager?.deleteJoint(joint);
90
+ joint.center = original;
91
+ this.manager?.addJoint(joint);
92
+ }
93
+ this.#originals.clear();
94
+ this.#centers.clear();
95
+ this.suspend();
96
+ }
97
+ }
@@ -0,0 +1,6 @@
1
+ import { type MountOptions } from "@starcried/starlite-core";
2
+ import type { VrmController, VrmWidgetConfig } from "./types.js";
3
+ export type * from "./types.js";
4
+ export { validateVrmWidgetConfig } from "./config.js";
5
+ export declare function mountModelWidget(host: HTMLElement, config: VrmWidgetConfig, options?: MountOptions): Promise<VrmController>;
6
+ export declare const mountVrmWidget: typeof mountModelWidget;