reze-engine 0.50.5 → 0.50.7
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/dist/_dbg-loader.mjs +1100 -0
- package/dist/camera-animation.d.ts +11 -1
- package/dist/camera-animation.d.ts.map +1 -1
- package/dist/camera-animation.js +14 -1
- package/dist/engine.d.ts +22 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +32 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/model.d.ts +14 -1
- package/dist/model.d.ts.map +1 -1
- package/dist/model.js +12 -2
- package/dist/physics-debug.d.ts +30 -0
- package/dist/physics-debug.d.ts.map +1 -0
- package/dist/physics-debug.js +526 -0
- package/dist/shaders/materials/body.d.ts +2 -0
- package/dist/shaders/materials/body.d.ts.map +1 -0
- package/dist/shaders/materials/body.js +95 -0
- package/dist/shaders/materials/cloth_rough.d.ts +2 -0
- package/dist/shaders/materials/cloth_rough.d.ts.map +1 -0
- package/dist/shaders/materials/cloth_rough.js +69 -0
- package/dist/shaders/materials/cloth_smooth.d.ts +2 -0
- package/dist/shaders/materials/cloth_smooth.d.ts.map +1 -0
- package/dist/shaders/materials/cloth_smooth.js +61 -0
- package/dist/shaders/materials/default.d.ts +2 -0
- package/dist/shaders/materials/default.d.ts.map +1 -0
- package/dist/shaders/materials/default.js +43 -0
- package/dist/shaders/materials/eye.d.ts +2 -0
- package/dist/shaders/materials/eye.d.ts.map +1 -0
- package/dist/shaders/materials/eye.js +60 -0
- package/dist/shaders/materials/face.d.ts +2 -0
- package/dist/shaders/materials/face.d.ts.map +1 -0
- package/dist/shaders/materials/face.js +95 -0
- package/dist/shaders/materials/hair.d.ts +2 -0
- package/dist/shaders/materials/hair.d.ts.map +1 -0
- package/dist/shaders/materials/hair.js +90 -0
- package/dist/shaders/materials/metal.d.ts +2 -0
- package/dist/shaders/materials/metal.d.ts.map +1 -0
- package/dist/shaders/materials/metal.js +77 -0
- package/dist/shaders/materials/mmd_classic.d.ts +2 -0
- package/dist/shaders/materials/mmd_classic.d.ts.map +1 -0
- package/dist/shaders/materials/mmd_classic.js +66 -0
- package/dist/shaders/materials/stockings.d.ts +2 -0
- package/dist/shaders/materials/stockings.d.ts.map +1 -0
- package/dist/shaders/materials/stockings.js +122 -0
- package/dist/shaders/passes/physics-debug.d.ts +2 -0
- package/dist/shaders/passes/physics-debug.d.ts.map +1 -0
- package/dist/shaders/passes/physics-debug.js +69 -0
- package/dist/vmd-loader.d.ts +9 -1
- package/dist/vmd-loader.d.ts.map +1 -1
- package/dist/vmd-loader.js +14 -0
- package/dist/vmd-writer.d.ts +34 -1
- package/dist/vmd-writer.d.ts.map +1 -1
- package/dist/vmd-writer.js +112 -18
- package/package.json +1 -1
- package/src/camera-animation.ts +15 -2
- package/src/engine.ts +35 -1
- package/src/index.ts +2 -2
- package/src/model.ts +13 -3
- package/src/vmd-loader.ts +20 -1
- package/src/vmd-writer.ts +121 -17
- package/dist/physics/autofit.d.ts +0 -147
- package/dist/physics/autofit.d.ts.map +0 -1
- package/dist/physics/autofit.js +0 -501
- package/dist/shaders/passes/field-blit.d.ts +0 -26
- package/dist/shaders/passes/field-blit.d.ts.map +0 -1
- package/dist/shaders/passes/field-blit.js +0 -65
- package/dist/shaders/passes/ground-noise.d.ts +0 -7
- package/dist/shaders/passes/ground-noise.d.ts.map +0 -1
- package/dist/shaders/passes/ground-noise.js +0 -88
- package/dist/shaders/passes/sim.d.ts +0 -34
- package/dist/shaders/passes/sim.d.ts.map +0 -1
- package/dist/shaders/passes/sim.js +0 -169
- package/dist/shaders/score-api.d.ts +0 -10
- package/dist/shaders/score-api.d.ts.map +0 -1
- package/dist/shaders/score-api.js +0 -114
package/dist/vmd-writer.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { DEFAULT_CAMERA_INTERPOLATION } from "./vmd-loader";
|
|
1
2
|
const VMD_HEADER = "Vocaloid Motion Data 0002";
|
|
2
3
|
const HEADER_SIZE = 30;
|
|
3
4
|
const MODEL_NAME_SIZE = 20;
|
|
@@ -7,6 +8,11 @@ const BONE_FRAME_SIZE = BONE_NAME_SIZE + 4 + 12 + 16 + 64; // 111 bytes
|
|
|
7
8
|
const MORPH_FRAME_SIZE = MORPH_NAME_SIZE + 4 + 4; // 23 bytes
|
|
8
9
|
/** IK bone names get 20 bytes in the IK block, not the 15 bones get elsewhere. */
|
|
9
10
|
const IK_NAME_SIZE = 20;
|
|
11
|
+
/** frame + distance + target(3) + rotation(3) + interpolation(24) + fov + perspective. */
|
|
12
|
+
const CAMERA_FRAME_SIZE = 4 + 4 + 12 + 12 + 24 + 4 + 1; // 61 bytes
|
|
13
|
+
/** What MMD stamps in the model-name field of a camera VMD. Tools sniff it to
|
|
14
|
+
* tell a camera file from a motion at a glance, so write what they expect. */
|
|
15
|
+
const CAMERA_MODEL_NAME = "\u30ab\u30e1\u30e9\u30fb\u7167\u660e";
|
|
10
16
|
// Build a Unicode-to-Shift-JIS lookup by inverting the TextDecoder mapping.
|
|
11
17
|
let shiftJISTable = null;
|
|
12
18
|
function getShiftJISTable() {
|
|
@@ -47,20 +53,27 @@ function encodeShiftJIS(str) {
|
|
|
47
53
|
return new Uint8Array(bytes);
|
|
48
54
|
}
|
|
49
55
|
export class VMDWriter {
|
|
50
|
-
write(clip) {
|
|
56
|
+
write(clip, options) {
|
|
57
|
+
const tracks = options?.tracks ?? "all";
|
|
58
|
+
const wantBones = tracks !== "morphs";
|
|
59
|
+
const wantMorphs = tracks !== "motion";
|
|
51
60
|
let totalBoneFrames = 0;
|
|
52
|
-
|
|
53
|
-
|
|
61
|
+
if (wantBones) {
|
|
62
|
+
for (const frames of clip.boneTracks.values()) {
|
|
63
|
+
totalBoneFrames += frames.length;
|
|
64
|
+
}
|
|
54
65
|
}
|
|
55
66
|
let totalMorphFrames = 0;
|
|
56
|
-
|
|
57
|
-
|
|
67
|
+
if (wantMorphs) {
|
|
68
|
+
for (const frames of clip.morphTracks.values()) {
|
|
69
|
+
totalMorphFrames += frames.length;
|
|
70
|
+
}
|
|
58
71
|
}
|
|
59
72
|
// IK state is stored per MOMENT, not per bone: one record lists every chain
|
|
60
73
|
// and its state at that frame. So the tracks are transposed back into the
|
|
61
74
|
// frames they were flattened from.
|
|
62
75
|
const ikByFrame = new Map();
|
|
63
|
-
for (const [boneName, keys] of clip.ikTracks ?? []) {
|
|
76
|
+
for (const [boneName, keys] of (wantBones ? clip.ikTracks : undefined) ?? []) {
|
|
64
77
|
for (const key of keys) {
|
|
65
78
|
const at = ikByFrame.get(key.frame);
|
|
66
79
|
if (at)
|
|
@@ -77,10 +90,17 @@ export class VMDWriter {
|
|
|
77
90
|
MODEL_NAME_SIZE +
|
|
78
91
|
4 + totalBoneFrames * BONE_FRAME_SIZE +
|
|
79
92
|
4 + totalMorphFrames * MORPH_FRAME_SIZE +
|
|
80
|
-
// Camera, light
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
|
|
93
|
+
// Camera, light, self-shadow and IK counts, ALWAYS — 16 bytes even when
|
|
94
|
+
// every one of them is zero.
|
|
95
|
+
//
|
|
96
|
+
// A VMD's sections are positional: a reader reaches each block by walking
|
|
97
|
+
// every count before it. Writing these only when there was IK state to
|
|
98
|
+
// carry left an ordinary motion ending right after the morph block, so
|
|
99
|
+
// anything looking for the sections that follow read past the end of the
|
|
100
|
+
// file and took whatever it found. MMD's own motion files carry all five
|
|
101
|
+
// counts unconditionally; so do ours now.
|
|
102
|
+
4 * 4 +
|
|
103
|
+
ikSize;
|
|
84
104
|
const buffer = new ArrayBuffer(size);
|
|
85
105
|
const view = new DataView(buffer);
|
|
86
106
|
let offset = 0;
|
|
@@ -92,7 +112,7 @@ export class VMDWriter {
|
|
|
92
112
|
view.setUint32(offset, totalBoneFrames, true);
|
|
93
113
|
offset += 4;
|
|
94
114
|
// Bone frames
|
|
95
|
-
for (const frames of clip.boneTracks.values()) {
|
|
115
|
+
for (const frames of wantBones ? clip.boneTracks.values() : []) {
|
|
96
116
|
for (const kf of frames) {
|
|
97
117
|
// Bone name (15 bytes, Shift-JIS)
|
|
98
118
|
offset = writeFixedShiftJIS(buffer, offset, kf.boneName, BONE_NAME_SIZE);
|
|
@@ -125,7 +145,7 @@ export class VMDWriter {
|
|
|
125
145
|
view.setUint32(offset, totalMorphFrames, true);
|
|
126
146
|
offset += 4;
|
|
127
147
|
// Morph frames
|
|
128
|
-
for (const frames of clip.morphTracks.values()) {
|
|
148
|
+
for (const frames of wantMorphs ? clip.morphTracks.values() : []) {
|
|
129
149
|
for (const kf of frames) {
|
|
130
150
|
// Morph name (15 bytes, Shift-JIS)
|
|
131
151
|
offset = writeFixedShiftJIS(buffer, offset, kf.morphName, MORPH_NAME_SIZE);
|
|
@@ -137,13 +157,14 @@ export class VMDWriter {
|
|
|
137
157
|
offset += 4;
|
|
138
158
|
}
|
|
139
159
|
}
|
|
160
|
+
// Empty camera, light and self-shadow blocks, then the IK count — written
|
|
161
|
+
// whether or not there is IK state, so the file always ends on a complete
|
|
162
|
+
// section table.
|
|
163
|
+
for (let i = 0; i < 3; i++, offset += 4)
|
|
164
|
+
view.setUint32(offset, 0, true);
|
|
165
|
+
view.setUint32(offset, ikFrames.length, true);
|
|
166
|
+
offset += 4;
|
|
140
167
|
if (ikFrames.length > 0) {
|
|
141
|
-
// Empty camera, light and self-shadow blocks — a reader walking the file
|
|
142
|
-
// in order has to pass through them to reach the IK block.
|
|
143
|
-
for (let i = 0; i < 3; i++, offset += 4)
|
|
144
|
-
view.setUint32(offset, 0, true);
|
|
145
|
-
view.setUint32(offset, ikFrames.length, true);
|
|
146
|
-
offset += 4;
|
|
147
168
|
for (const [frame, states] of ikFrames) {
|
|
148
169
|
view.setUint32(offset, frame, true);
|
|
149
170
|
offset += 4;
|
|
@@ -160,6 +181,79 @@ export class VMDWriter {
|
|
|
160
181
|
}
|
|
161
182
|
return buffer;
|
|
162
183
|
}
|
|
184
|
+
/**
|
|
185
|
+
* A camera VMD: the shot's own file, with no model motion in it.
|
|
186
|
+
*
|
|
187
|
+
* Every section count is written, including the empty ones. The format is
|
|
188
|
+
* positional, so a reader reaches each block by walking the counts before it —
|
|
189
|
+
* bone and morph to arrive at the camera block, and light, self-shadow and IK
|
|
190
|
+
* to leave it cleanly.
|
|
191
|
+
*
|
|
192
|
+
* The trailing three used to be omitted, on the theory that every reader
|
|
193
|
+
* bounds-checks past the camera block. They do not. This file type is
|
|
194
|
+
* カメラ・照明 — camera AND lighting — so MMD looks for the light block exactly
|
|
195
|
+
* where the file used to stop, read past the end, and lit the scene from
|
|
196
|
+
* whatever it found there. It reached users as a stray coloured light on a
|
|
197
|
+
* camera-only export.
|
|
198
|
+
*
|
|
199
|
+
* `frames` is sorted by frame on the way out: CameraAnimation binary-searches
|
|
200
|
+
* the track it loads, and an out-of-order file would sample wrong rather than
|
|
201
|
+
* fail loudly.
|
|
202
|
+
*/
|
|
203
|
+
writeCamera(frames) {
|
|
204
|
+
const sorted = [...frames].sort((a, b) => a.frame - b.frame);
|
|
205
|
+
const size = HEADER_SIZE + MODEL_NAME_SIZE + 4 + 4 + 4 + sorted.length * CAMERA_FRAME_SIZE + 4 * 3;
|
|
206
|
+
const buffer = new ArrayBuffer(size);
|
|
207
|
+
const view = new DataView(buffer);
|
|
208
|
+
let offset = 0;
|
|
209
|
+
offset = writeFixedString(buffer, offset, VMD_HEADER, HEADER_SIZE);
|
|
210
|
+
offset = writeFixedShiftJIS(buffer, offset, CAMERA_MODEL_NAME, MODEL_NAME_SIZE);
|
|
211
|
+
view.setUint32(offset, 0, true); // bone frame count
|
|
212
|
+
offset += 4;
|
|
213
|
+
view.setUint32(offset, 0, true); // morph frame count
|
|
214
|
+
offset += 4;
|
|
215
|
+
view.setUint32(offset, sorted.length, true);
|
|
216
|
+
offset += 4;
|
|
217
|
+
for (const kf of sorted) {
|
|
218
|
+
view.setUint32(offset, kf.frame, true);
|
|
219
|
+
offset += 4;
|
|
220
|
+
view.setFloat32(offset, kf.distance, true);
|
|
221
|
+
offset += 4;
|
|
222
|
+
view.setFloat32(offset, kf.target.x, true);
|
|
223
|
+
offset += 4;
|
|
224
|
+
view.setFloat32(offset, kf.target.y, true);
|
|
225
|
+
offset += 4;
|
|
226
|
+
view.setFloat32(offset, kf.target.z, true);
|
|
227
|
+
offset += 4;
|
|
228
|
+
// Euler radians, as the loader reads them.
|
|
229
|
+
view.setFloat32(offset, kf.rotation.x, true);
|
|
230
|
+
offset += 4;
|
|
231
|
+
view.setFloat32(offset, kf.rotation.y, true);
|
|
232
|
+
offset += 4;
|
|
233
|
+
view.setFloat32(offset, kf.rotation.z, true);
|
|
234
|
+
offset += 4;
|
|
235
|
+
// 24 bytes, contiguous per channel — see camera-animation.ts's `bez`.
|
|
236
|
+
// Short or missing tables are padded with a linear default rather than
|
|
237
|
+
// writing junk: a hand-built keyframe should not have to know the layout.
|
|
238
|
+
const ip = new Uint8Array(24);
|
|
239
|
+
ip.set(DEFAULT_CAMERA_INTERPOLATION);
|
|
240
|
+
if (kf.interpolation)
|
|
241
|
+
ip.set(kf.interpolation.subarray(0, 24));
|
|
242
|
+
new Uint8Array(buffer, offset, 24).set(ip);
|
|
243
|
+
offset += 24;
|
|
244
|
+
// fov is degrees, and an integer in the file — MMD's own field is u32.
|
|
245
|
+
view.setUint32(offset, Math.max(0, Math.round(kf.fov)), true);
|
|
246
|
+
offset += 4;
|
|
247
|
+
view.setUint8(offset, 0); // 0 = perspective
|
|
248
|
+
offset += 1;
|
|
249
|
+
}
|
|
250
|
+
// Light, self-shadow and IK/display counts. See the note above: the light
|
|
251
|
+
// block begins here, and leaving it off is what lit MMD from past the end
|
|
252
|
+
// of the file.
|
|
253
|
+
for (let i = 0; i < 3; i++, offset += 4)
|
|
254
|
+
view.setUint32(offset, 0, true);
|
|
255
|
+
return buffer;
|
|
256
|
+
}
|
|
163
257
|
}
|
|
164
258
|
function writeFixedString(buffer, offset, str, maxBytes) {
|
|
165
259
|
const bytes = new Uint8Array(buffer, offset, maxBytes);
|
package/package.json
CHANGED
package/src/camera-animation.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { Vec3 } from "./math"
|
|
6
6
|
import { bezierInterpolate } from "./animation"
|
|
7
|
-
import type
|
|
7
|
+
import { DEFAULT_CAMERA_INTERPOLATION, type CameraKeyframe } from "./vmd-loader"
|
|
8
8
|
|
|
9
9
|
const FPS = 30
|
|
10
10
|
const DEG2RAD = Math.PI / 180
|
|
@@ -53,6 +53,19 @@ export class CameraAnimation {
|
|
|
53
53
|
return this.frames.map((f) => f.frame)
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Every keyframe, in full — the track as an editable document.
|
|
58
|
+
*
|
|
59
|
+
* The counterpart to keyframeIndices(), which deliberately withholds the
|
|
60
|
+
* poses because a timeline only wants the rhythm. An EDITOR wants the poses:
|
|
61
|
+
* it has to show what the shot does at a cut, let it be changed, and write
|
|
62
|
+
* the result back out. Shallow copies, so a host mutating what it gets back
|
|
63
|
+
* cannot reach into the track this is sampling from mid-playback.
|
|
64
|
+
*/
|
|
65
|
+
keyframes(): CameraKeyframe[] {
|
|
66
|
+
return this.frames.map((f) => ({ ...f }))
|
|
67
|
+
}
|
|
68
|
+
|
|
56
69
|
/** Sample the camera pose at time `t` (seconds). Clamps to the track ends; null if empty. */
|
|
57
70
|
sample(t: number): CameraPose | null {
|
|
58
71
|
const frames = this.frames
|
|
@@ -76,7 +89,7 @@ export class CameraAnimation {
|
|
|
76
89
|
const span = b.frame - a.frame
|
|
77
90
|
const localT = span > 0 ? (frame - a.frame) / span : 0
|
|
78
91
|
// The incoming interpolation curve is stored on the segment's end keyframe (b).
|
|
79
|
-
const ip = b.interpolation
|
|
92
|
+
const ip = b.interpolation ?? DEFAULT_CAMERA_INTERPOLATION
|
|
80
93
|
|
|
81
94
|
return {
|
|
82
95
|
target: new Vec3(
|
package/src/engine.ts
CHANGED
|
@@ -8,7 +8,8 @@ import { CULL_COMPUTE_WGSL } from "./shaders/passes/cull"
|
|
|
8
8
|
import { buildAnchorTable, anchorAliasWgsl, EMPTY_ANCHOR_TABLE, type AnchorTable } from "./shaders/anchor-table"
|
|
9
9
|
import { MIDI_HEADER, MIDI_KEYS, MIDI_NOTES, MIDI_STRIDE } from "./shaders/midi-api"
|
|
10
10
|
import { decodeTga } from "./tga-loader"
|
|
11
|
-
import { VMDLoader } from "./vmd-loader"
|
|
11
|
+
import { VMDLoader, type CameraKeyframe } from "./vmd-loader"
|
|
12
|
+
import { VMDWriter } from "./vmd-writer"
|
|
12
13
|
import { CameraAnimation } from "./camera-animation"
|
|
13
14
|
import { PmxLoader } from "./pmx-loader"
|
|
14
15
|
import { RezePhysics } from "./physics"
|
|
@@ -6510,6 +6511,39 @@ export class Engine {
|
|
|
6510
6511
|
this.camera.setVmdDriven(this.cameraAnimation !== null)
|
|
6511
6512
|
}
|
|
6512
6513
|
|
|
6514
|
+
/**
|
|
6515
|
+
* Drive the shot from camera keyframes built in JS — the camera's answer to
|
|
6516
|
+
* `Model.loadClip`.
|
|
6517
|
+
*
|
|
6518
|
+
* The two loadCameraVmd* methods take FILE BYTES, which is all a viewer ever
|
|
6519
|
+
* needs. An editor needs the other direction: hold the track as data, change
|
|
6520
|
+
* a keyframe, and see the result immediately. Going through the writer and
|
|
6521
|
+
* back through the parser for every edit would work and would be absurd.
|
|
6522
|
+
*
|
|
6523
|
+
* Empty (or an empty array) clears the track and returns the camera to orbit,
|
|
6524
|
+
* same as clearCameraVmd — a track with no keyframes cannot drive anything,
|
|
6525
|
+
* and silently keeping the previous one would be worse than saying so.
|
|
6526
|
+
*/
|
|
6527
|
+
loadCameraClip(frames: CameraKeyframe[]): void {
|
|
6528
|
+
this.cameraAnimation = frames.length ? new CameraAnimation([...frames]) : null
|
|
6529
|
+
this.camera.setVmdDriven(this.cameraAnimation !== null)
|
|
6530
|
+
}
|
|
6531
|
+
|
|
6532
|
+
/** The loaded camera track as editable keyframes, or [] with none loaded.
|
|
6533
|
+
* Copies — mutating them does not reach the track being sampled. */
|
|
6534
|
+
getCameraClip(): CameraKeyframe[] {
|
|
6535
|
+
return this.cameraAnimation?.keyframes() ?? []
|
|
6536
|
+
}
|
|
6537
|
+
|
|
6538
|
+
/** The loaded camera track as camera-VMD bytes. Throws with none loaded:
|
|
6539
|
+
* writing an empty camera file is a mistake worth hearing about, not a
|
|
6540
|
+
* 30-byte header to hand someone as a download. */
|
|
6541
|
+
exportCameraVmd(): ArrayBuffer {
|
|
6542
|
+
const frames = this.cameraAnimation?.keyframes()
|
|
6543
|
+
if (!frames?.length) throw new Error("No camera track loaded")
|
|
6544
|
+
return new VMDWriter().writeCamera(frames)
|
|
6545
|
+
}
|
|
6546
|
+
|
|
6513
6547
|
/** Turn the loaded camera VMD on/off (falls back to orbit when off). No-op if none loaded. */
|
|
6514
6548
|
setCameraVmdEnabled(enabled: boolean): void {
|
|
6515
6549
|
this.camera.setVmdDriven(enabled && this.cameraAnimation !== null)
|
package/src/index.ts
CHANGED
|
@@ -109,8 +109,8 @@ export {
|
|
|
109
109
|
interpolateControlPoints,
|
|
110
110
|
rawInterpolationToBoneInterpolation,
|
|
111
111
|
} from "./animation"
|
|
112
|
-
export { VMDLoader, type CameraKeyframe, type IkFrame } from "./vmd-loader"
|
|
113
|
-
export { VMDWriter } from "./vmd-writer"
|
|
112
|
+
export { VMDLoader, DEFAULT_CAMERA_INTERPOLATION, type CameraKeyframe, type IkFrame } from "./vmd-loader"
|
|
113
|
+
export { VMDWriter, type VmdTrackSelection } from "./vmd-writer"
|
|
114
114
|
export { PmxLoader } from "./pmx-loader"
|
|
115
115
|
export { CameraAnimation, type CameraPose } from "./camera-animation"
|
|
116
116
|
export { RezePhysics } from "./physics"
|
package/src/model.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { joinAssetPath, type AssetReader } from "./asset-reader"
|
|
|
4
4
|
import { Rigidbody, Joint } from "./physics"
|
|
5
5
|
import { IKSolverSystem } from "./ik-solver"
|
|
6
6
|
import { VMDLoader, type VMDKeyFrame } from "./vmd-loader"
|
|
7
|
-
import { VMDWriter } from "./vmd-writer"
|
|
7
|
+
import { VMDWriter, type VmdTrackSelection } from "./vmd-writer"
|
|
8
8
|
import {
|
|
9
9
|
AnimationClip,
|
|
10
10
|
AnimationPlayOptions,
|
|
@@ -1720,10 +1720,20 @@ export class Model {
|
|
|
1720
1720
|
return values[i - 1] + (values[i] - values[i - 1]) * t
|
|
1721
1721
|
}
|
|
1722
1722
|
|
|
1723
|
-
|
|
1723
|
+
/**
|
|
1724
|
+
* The clip called `name` as VMD bytes.
|
|
1725
|
+
*
|
|
1726
|
+
* `tracks` splits the file the same way `loadVmd`'s option splits what it
|
|
1727
|
+
* reads: "motion" is the dance (bones + IK), "morphs" is the expression file
|
|
1728
|
+
* to lay over one, "all" (the default) is both in one file as MMD exports it.
|
|
1729
|
+
* A clip missing the half you asked for still writes — an empty motion or an
|
|
1730
|
+
* empty expression file is a valid VMD, and is the honest answer to "export
|
|
1731
|
+
* the morphs" for a clip that has none.
|
|
1732
|
+
*/
|
|
1733
|
+
exportVmd(name: string, options?: { tracks?: VmdTrackSelection }): ArrayBuffer {
|
|
1724
1734
|
const clip = this.animationState.getAnimationClip(name)
|
|
1725
1735
|
if (!clip) throw new Error(`Animation clip "${name}" not found`)
|
|
1726
|
-
return new VMDWriter().write(clip)
|
|
1736
|
+
return new VMDWriter().write(clip, options)
|
|
1727
1737
|
}
|
|
1728
1738
|
|
|
1729
1739
|
play(): void
|
package/src/vmd-loader.ts
CHANGED
|
@@ -29,9 +29,28 @@ export interface CameraKeyframe {
|
|
|
29
29
|
target: Vec3
|
|
30
30
|
rotation: Vec3 // euler radians
|
|
31
31
|
fov: number // degrees
|
|
32
|
-
|
|
32
|
+
/** 24 bytes, contiguous per channel — see camera-animation.ts's `bez`.
|
|
33
|
+
* Optional so a hand-authored keyframe does not have to know the layout;
|
|
34
|
+
* both the sampler and the writer fall back to DEFAULT_CAMERA_INTERPOLATION.
|
|
35
|
+
* A parsed file always carries its own. */
|
|
36
|
+
interpolation?: Uint8Array
|
|
33
37
|
}
|
|
34
38
|
|
|
39
|
+
/** Linear in, linear out on all six channels. 20/107 is MMD's own linear pair
|
|
40
|
+
* (its bezier bytes run 0-127), so a keyframe written with this reads back as
|
|
41
|
+
* a straight line in MMD rather than an ease nobody asked for. */
|
|
42
|
+
export const DEFAULT_CAMERA_INTERPOLATION: Uint8Array = (() => {
|
|
43
|
+
const ip = new Uint8Array(24)
|
|
44
|
+
for (let c = 0; c < 6; c++) {
|
|
45
|
+
const b = c * 4
|
|
46
|
+
ip[b] = 20 // x1
|
|
47
|
+
ip[b + 1] = 107 // x2
|
|
48
|
+
ip[b + 2] = 20 // y1
|
|
49
|
+
ip[b + 3] = 107 // y2
|
|
50
|
+
}
|
|
51
|
+
return ip
|
|
52
|
+
})()
|
|
53
|
+
|
|
35
54
|
/** A VMD "IK/display" record: one moment at which chains are switched. */
|
|
36
55
|
export interface IkFrame {
|
|
37
56
|
frame: number
|
package/src/vmd-writer.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { AnimationClip, BoneInterpolation, ControlPoint } from "./animation"
|
|
2
|
+
import { DEFAULT_CAMERA_INTERPOLATION, type CameraKeyframe } from "./vmd-loader"
|
|
2
3
|
|
|
3
4
|
const VMD_HEADER = "Vocaloid Motion Data 0002"
|
|
4
5
|
const HEADER_SIZE = 30
|
|
@@ -9,6 +10,11 @@ const BONE_FRAME_SIZE = BONE_NAME_SIZE + 4 + 12 + 16 + 64 // 111 bytes
|
|
|
9
10
|
const MORPH_FRAME_SIZE = MORPH_NAME_SIZE + 4 + 4 // 23 bytes
|
|
10
11
|
/** IK bone names get 20 bytes in the IK block, not the 15 bones get elsewhere. */
|
|
11
12
|
const IK_NAME_SIZE = 20
|
|
13
|
+
/** frame + distance + target(3) + rotation(3) + interpolation(24) + fov + perspective. */
|
|
14
|
+
const CAMERA_FRAME_SIZE = 4 + 4 + 12 + 12 + 24 + 4 + 1 // 61 bytes
|
|
15
|
+
/** What MMD stamps in the model-name field of a camera VMD. Tools sniff it to
|
|
16
|
+
* tell a camera file from a motion at a glance, so write what they expect. */
|
|
17
|
+
const CAMERA_MODEL_NAME = "\u30ab\u30e1\u30e9\u30fb\u7167\u660e"
|
|
12
18
|
|
|
13
19
|
// Build a Unicode-to-Shift-JIS lookup by inverting the TextDecoder mapping.
|
|
14
20
|
let shiftJISTable: Map<string, number[]> | null = null
|
|
@@ -47,22 +53,41 @@ function encodeShiftJIS(str: string): Uint8Array {
|
|
|
47
53
|
return new Uint8Array(bytes)
|
|
48
54
|
}
|
|
49
55
|
|
|
56
|
+
/** Which half of a clip to write. Mirrors `Model.loadVmd`'s `tracks` option, so
|
|
57
|
+
* a file this writer splits out is one the loader can read straight back:
|
|
58
|
+
*
|
|
59
|
+
* "all" bone + morph (+ IK) — one file, what MMD itself exports
|
|
60
|
+
* "motion" bone (+ IK) only — the dance, no expressions
|
|
61
|
+
* "morphs" morph only — an expression file (\u8868\u60c5\u30e2\u30fc\u30b7\u30e7\u30f3) to lay over a motion
|
|
62
|
+
*
|
|
63
|
+
* IK rides with "motion" rather than "morphs" because it is bone state: which
|
|
64
|
+
* chains solve says nothing about a face. */
|
|
65
|
+
export type VmdTrackSelection = "all" | "motion" | "morphs"
|
|
66
|
+
|
|
50
67
|
export class VMDWriter {
|
|
51
|
-
write(clip: AnimationClip): ArrayBuffer {
|
|
68
|
+
write(clip: AnimationClip, options?: { tracks?: VmdTrackSelection }): ArrayBuffer {
|
|
69
|
+
const tracks = options?.tracks ?? "all"
|
|
70
|
+
const wantBones = tracks !== "morphs"
|
|
71
|
+
const wantMorphs = tracks !== "motion"
|
|
72
|
+
|
|
52
73
|
let totalBoneFrames = 0
|
|
53
|
-
|
|
54
|
-
|
|
74
|
+
if (wantBones) {
|
|
75
|
+
for (const frames of clip.boneTracks.values()) {
|
|
76
|
+
totalBoneFrames += frames.length
|
|
77
|
+
}
|
|
55
78
|
}
|
|
56
79
|
let totalMorphFrames = 0
|
|
57
|
-
|
|
58
|
-
|
|
80
|
+
if (wantMorphs) {
|
|
81
|
+
for (const frames of clip.morphTracks.values()) {
|
|
82
|
+
totalMorphFrames += frames.length
|
|
83
|
+
}
|
|
59
84
|
}
|
|
60
85
|
|
|
61
86
|
// IK state is stored per MOMENT, not per bone: one record lists every chain
|
|
62
87
|
// and its state at that frame. So the tracks are transposed back into the
|
|
63
88
|
// frames they were flattened from.
|
|
64
89
|
const ikByFrame = new Map<number, { boneName: string; enabled: boolean }[]>()
|
|
65
|
-
for (const [boneName, keys] of clip.ikTracks ?? []) {
|
|
90
|
+
for (const [boneName, keys] of (wantBones ? clip.ikTracks : undefined) ?? []) {
|
|
66
91
|
for (const key of keys) {
|
|
67
92
|
const at = ikByFrame.get(key.frame)
|
|
68
93
|
if (at) at.push({ boneName, enabled: key.enabled })
|
|
@@ -78,10 +103,17 @@ export class VMDWriter {
|
|
|
78
103
|
MODEL_NAME_SIZE +
|
|
79
104
|
4 + totalBoneFrames * BONE_FRAME_SIZE +
|
|
80
105
|
4 + totalMorphFrames * MORPH_FRAME_SIZE +
|
|
81
|
-
// Camera, light
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
|
|
106
|
+
// Camera, light, self-shadow and IK counts, ALWAYS — 16 bytes even when
|
|
107
|
+
// every one of them is zero.
|
|
108
|
+
//
|
|
109
|
+
// A VMD's sections are positional: a reader reaches each block by walking
|
|
110
|
+
// every count before it. Writing these only when there was IK state to
|
|
111
|
+
// carry left an ordinary motion ending right after the morph block, so
|
|
112
|
+
// anything looking for the sections that follow read past the end of the
|
|
113
|
+
// file and took whatever it found. MMD's own motion files carry all five
|
|
114
|
+
// counts unconditionally; so do ours now.
|
|
115
|
+
4 * 4 +
|
|
116
|
+
ikSize
|
|
85
117
|
|
|
86
118
|
const buffer = new ArrayBuffer(size)
|
|
87
119
|
const view = new DataView(buffer)
|
|
@@ -98,7 +130,7 @@ export class VMDWriter {
|
|
|
98
130
|
offset += 4
|
|
99
131
|
|
|
100
132
|
// Bone frames
|
|
101
|
-
for (const frames of clip.boneTracks.values()) {
|
|
133
|
+
for (const frames of wantBones ? clip.boneTracks.values() : []) {
|
|
102
134
|
for (const kf of frames) {
|
|
103
135
|
// Bone name (15 bytes, Shift-JIS)
|
|
104
136
|
offset = writeFixedShiftJIS(buffer, offset, kf.boneName, BONE_NAME_SIZE)
|
|
@@ -130,7 +162,7 @@ export class VMDWriter {
|
|
|
130
162
|
offset += 4
|
|
131
163
|
|
|
132
164
|
// Morph frames
|
|
133
|
-
for (const frames of clip.morphTracks.values()) {
|
|
165
|
+
for (const frames of wantMorphs ? clip.morphTracks.values() : []) {
|
|
134
166
|
for (const kf of frames) {
|
|
135
167
|
// Morph name (15 bytes, Shift-JIS)
|
|
136
168
|
offset = writeFixedShiftJIS(buffer, offset, kf.morphName, MORPH_NAME_SIZE)
|
|
@@ -145,12 +177,14 @@ export class VMDWriter {
|
|
|
145
177
|
}
|
|
146
178
|
}
|
|
147
179
|
|
|
180
|
+
// Empty camera, light and self-shadow blocks, then the IK count — written
|
|
181
|
+
// whether or not there is IK state, so the file always ends on a complete
|
|
182
|
+
// section table.
|
|
183
|
+
for (let i = 0; i < 3; i++, offset += 4) view.setUint32(offset, 0, true)
|
|
184
|
+
view.setUint32(offset, ikFrames.length, true)
|
|
185
|
+
offset += 4
|
|
186
|
+
|
|
148
187
|
if (ikFrames.length > 0) {
|
|
149
|
-
// Empty camera, light and self-shadow blocks — a reader walking the file
|
|
150
|
-
// in order has to pass through them to reach the IK block.
|
|
151
|
-
for (let i = 0; i < 3; i++, offset += 4) view.setUint32(offset, 0, true)
|
|
152
|
-
view.setUint32(offset, ikFrames.length, true)
|
|
153
|
-
offset += 4
|
|
154
188
|
for (const [frame, states] of ikFrames) {
|
|
155
189
|
view.setUint32(offset, frame, true)
|
|
156
190
|
offset += 4
|
|
@@ -168,8 +202,78 @@ export class VMDWriter {
|
|
|
168
202
|
|
|
169
203
|
return buffer
|
|
170
204
|
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* A camera VMD: the shot's own file, with no model motion in it.
|
|
208
|
+
*
|
|
209
|
+
* Every section count is written, including the empty ones. The format is
|
|
210
|
+
* positional, so a reader reaches each block by walking the counts before it —
|
|
211
|
+
* bone and morph to arrive at the camera block, and light, self-shadow and IK
|
|
212
|
+
* to leave it cleanly.
|
|
213
|
+
*
|
|
214
|
+
* The trailing three used to be omitted, on the theory that every reader
|
|
215
|
+
* bounds-checks past the camera block. They do not. This file type is
|
|
216
|
+
* カメラ・照明 — camera AND lighting — so MMD looks for the light block exactly
|
|
217
|
+
* where the file used to stop, read past the end, and lit the scene from
|
|
218
|
+
* whatever it found there. It reached users as a stray coloured light on a
|
|
219
|
+
* camera-only export.
|
|
220
|
+
*
|
|
221
|
+
* `frames` is sorted by frame on the way out: CameraAnimation binary-searches
|
|
222
|
+
* the track it loads, and an out-of-order file would sample wrong rather than
|
|
223
|
+
* fail loudly.
|
|
224
|
+
*/
|
|
225
|
+
writeCamera(frames: CameraKeyframe[]): ArrayBuffer {
|
|
226
|
+
const sorted = [...frames].sort((a, b) => a.frame - b.frame)
|
|
227
|
+
const size =
|
|
228
|
+
HEADER_SIZE + MODEL_NAME_SIZE + 4 + 4 + 4 + sorted.length * CAMERA_FRAME_SIZE + 4 * 3
|
|
229
|
+
const buffer = new ArrayBuffer(size)
|
|
230
|
+
const view = new DataView(buffer)
|
|
231
|
+
let offset = 0
|
|
232
|
+
|
|
233
|
+
offset = writeFixedString(buffer, offset, VMD_HEADER, HEADER_SIZE)
|
|
234
|
+
offset = writeFixedShiftJIS(buffer, offset, CAMERA_MODEL_NAME, MODEL_NAME_SIZE)
|
|
235
|
+
|
|
236
|
+
view.setUint32(offset, 0, true) // bone frame count
|
|
237
|
+
offset += 4
|
|
238
|
+
view.setUint32(offset, 0, true) // morph frame count
|
|
239
|
+
offset += 4
|
|
240
|
+
view.setUint32(offset, sorted.length, true)
|
|
241
|
+
offset += 4
|
|
242
|
+
|
|
243
|
+
for (const kf of sorted) {
|
|
244
|
+
view.setUint32(offset, kf.frame, true); offset += 4
|
|
245
|
+
view.setFloat32(offset, kf.distance, true); offset += 4
|
|
246
|
+
view.setFloat32(offset, kf.target.x, true); offset += 4
|
|
247
|
+
view.setFloat32(offset, kf.target.y, true); offset += 4
|
|
248
|
+
view.setFloat32(offset, kf.target.z, true); offset += 4
|
|
249
|
+
// Euler radians, as the loader reads them.
|
|
250
|
+
view.setFloat32(offset, kf.rotation.x, true); offset += 4
|
|
251
|
+
view.setFloat32(offset, kf.rotation.y, true); offset += 4
|
|
252
|
+
view.setFloat32(offset, kf.rotation.z, true); offset += 4
|
|
253
|
+
// 24 bytes, contiguous per channel — see camera-animation.ts's `bez`.
|
|
254
|
+
// Short or missing tables are padded with a linear default rather than
|
|
255
|
+
// writing junk: a hand-built keyframe should not have to know the layout.
|
|
256
|
+
const ip = new Uint8Array(24)
|
|
257
|
+
ip.set(DEFAULT_CAMERA_INTERPOLATION)
|
|
258
|
+
if (kf.interpolation) ip.set(kf.interpolation.subarray(0, 24))
|
|
259
|
+
new Uint8Array(buffer, offset, 24).set(ip)
|
|
260
|
+
offset += 24
|
|
261
|
+
// fov is degrees, and an integer in the file — MMD's own field is u32.
|
|
262
|
+
view.setUint32(offset, Math.max(0, Math.round(kf.fov)), true); offset += 4
|
|
263
|
+
view.setUint8(offset, 0) // 0 = perspective
|
|
264
|
+
offset += 1
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Light, self-shadow and IK/display counts. See the note above: the light
|
|
268
|
+
// block begins here, and leaving it off is what lit MMD from past the end
|
|
269
|
+
// of the file.
|
|
270
|
+
for (let i = 0; i < 3; i++, offset += 4) view.setUint32(offset, 0, true)
|
|
271
|
+
|
|
272
|
+
return buffer
|
|
273
|
+
}
|
|
171
274
|
}
|
|
172
275
|
|
|
276
|
+
|
|
173
277
|
function writeFixedString(buffer: ArrayBuffer, offset: number, str: string, maxBytes: number): number {
|
|
174
278
|
const bytes = new Uint8Array(buffer, offset, maxBytes)
|
|
175
279
|
bytes.fill(0)
|