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.
Files changed (77) hide show
  1. package/dist/_dbg-loader.mjs +1100 -0
  2. package/dist/camera-animation.d.ts +11 -1
  3. package/dist/camera-animation.d.ts.map +1 -1
  4. package/dist/camera-animation.js +14 -1
  5. package/dist/engine.d.ts +22 -0
  6. package/dist/engine.d.ts.map +1 -1
  7. package/dist/engine.js +32 -0
  8. package/dist/index.d.ts +2 -2
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +1 -1
  11. package/dist/model.d.ts +14 -1
  12. package/dist/model.d.ts.map +1 -1
  13. package/dist/model.js +12 -2
  14. package/dist/physics-debug.d.ts +30 -0
  15. package/dist/physics-debug.d.ts.map +1 -0
  16. package/dist/physics-debug.js +526 -0
  17. package/dist/shaders/materials/body.d.ts +2 -0
  18. package/dist/shaders/materials/body.d.ts.map +1 -0
  19. package/dist/shaders/materials/body.js +95 -0
  20. package/dist/shaders/materials/cloth_rough.d.ts +2 -0
  21. package/dist/shaders/materials/cloth_rough.d.ts.map +1 -0
  22. package/dist/shaders/materials/cloth_rough.js +69 -0
  23. package/dist/shaders/materials/cloth_smooth.d.ts +2 -0
  24. package/dist/shaders/materials/cloth_smooth.d.ts.map +1 -0
  25. package/dist/shaders/materials/cloth_smooth.js +61 -0
  26. package/dist/shaders/materials/default.d.ts +2 -0
  27. package/dist/shaders/materials/default.d.ts.map +1 -0
  28. package/dist/shaders/materials/default.js +43 -0
  29. package/dist/shaders/materials/eye.d.ts +2 -0
  30. package/dist/shaders/materials/eye.d.ts.map +1 -0
  31. package/dist/shaders/materials/eye.js +60 -0
  32. package/dist/shaders/materials/face.d.ts +2 -0
  33. package/dist/shaders/materials/face.d.ts.map +1 -0
  34. package/dist/shaders/materials/face.js +95 -0
  35. package/dist/shaders/materials/hair.d.ts +2 -0
  36. package/dist/shaders/materials/hair.d.ts.map +1 -0
  37. package/dist/shaders/materials/hair.js +90 -0
  38. package/dist/shaders/materials/metal.d.ts +2 -0
  39. package/dist/shaders/materials/metal.d.ts.map +1 -0
  40. package/dist/shaders/materials/metal.js +77 -0
  41. package/dist/shaders/materials/mmd_classic.d.ts +2 -0
  42. package/dist/shaders/materials/mmd_classic.d.ts.map +1 -0
  43. package/dist/shaders/materials/mmd_classic.js +66 -0
  44. package/dist/shaders/materials/stockings.d.ts +2 -0
  45. package/dist/shaders/materials/stockings.d.ts.map +1 -0
  46. package/dist/shaders/materials/stockings.js +122 -0
  47. package/dist/shaders/passes/physics-debug.d.ts +2 -0
  48. package/dist/shaders/passes/physics-debug.d.ts.map +1 -0
  49. package/dist/shaders/passes/physics-debug.js +69 -0
  50. package/dist/vmd-loader.d.ts +9 -1
  51. package/dist/vmd-loader.d.ts.map +1 -1
  52. package/dist/vmd-loader.js +14 -0
  53. package/dist/vmd-writer.d.ts +34 -1
  54. package/dist/vmd-writer.d.ts.map +1 -1
  55. package/dist/vmd-writer.js +112 -18
  56. package/package.json +1 -1
  57. package/src/camera-animation.ts +15 -2
  58. package/src/engine.ts +35 -1
  59. package/src/index.ts +2 -2
  60. package/src/model.ts +13 -3
  61. package/src/vmd-loader.ts +20 -1
  62. package/src/vmd-writer.ts +121 -17
  63. package/dist/physics/autofit.d.ts +0 -147
  64. package/dist/physics/autofit.d.ts.map +0 -1
  65. package/dist/physics/autofit.js +0 -501
  66. package/dist/shaders/passes/field-blit.d.ts +0 -26
  67. package/dist/shaders/passes/field-blit.d.ts.map +0 -1
  68. package/dist/shaders/passes/field-blit.js +0 -65
  69. package/dist/shaders/passes/ground-noise.d.ts +0 -7
  70. package/dist/shaders/passes/ground-noise.d.ts.map +0 -1
  71. package/dist/shaders/passes/ground-noise.js +0 -88
  72. package/dist/shaders/passes/sim.d.ts +0 -34
  73. package/dist/shaders/passes/sim.d.ts.map +0 -1
  74. package/dist/shaders/passes/sim.js +0 -169
  75. package/dist/shaders/score-api.d.ts +0 -10
  76. package/dist/shaders/score-api.d.ts.map +0 -1
  77. package/dist/shaders/score-api.js +0 -114
@@ -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
- for (const frames of clip.boneTracks.values()) {
53
- totalBoneFrames += frames.length;
61
+ if (wantBones) {
62
+ for (const frames of clip.boneTracks.values()) {
63
+ totalBoneFrames += frames.length;
64
+ }
54
65
  }
55
66
  let totalMorphFrames = 0;
56
- for (const frames of clip.morphTracks.values()) {
57
- totalMorphFrames += frames.length;
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 and self-shadow counts, then the IK block. Written only
81
- // when there is IK state to carry: a plain motion stays byte-identical to
82
- // what this writer produced before.
83
- (ikFrames.length > 0 ? 4 * 4 + ikSize : 0);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reze-engine",
3
- "version": "0.50.5",
3
+ "version": "0.50.7",
4
4
  "description": "A lightweight WebGPU engine for real-time 3D MMD/PMX model rendering",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { Vec3 } from "./math"
6
6
  import { bezierInterpolate } from "./animation"
7
- import type { CameraKeyframe } from "./vmd-loader"
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
- exportVmd(name: string): ArrayBuffer {
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
- interpolation: Uint8Array // 24 bytes
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
- for (const frames of clip.boneTracks.values()) {
54
- totalBoneFrames += frames.length
74
+ if (wantBones) {
75
+ for (const frames of clip.boneTracks.values()) {
76
+ totalBoneFrames += frames.length
77
+ }
55
78
  }
56
79
  let totalMorphFrames = 0
57
- for (const frames of clip.morphTracks.values()) {
58
- totalMorphFrames += frames.length
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 and self-shadow counts, then the IK block. Written only
82
- // when there is IK state to carry: a plain motion stays byte-identical to
83
- // what this writer produced before.
84
- (ikFrames.length > 0 ? 4 * 4 + ikSize : 0)
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)