reze-engine 0.21.2 → 0.22.1
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 +54 -44
- package/dist/camera-animation.d.ts +20 -0
- package/dist/camera-animation.d.ts.map +1 -0
- package/dist/camera-animation.js +66 -0
- package/dist/camera.d.ts +12 -0
- package/dist/camera.d.ts.map +1 -1
- package/dist/camera.js +51 -3
- package/dist/engine.d.ts +34 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +107 -3
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/math.d.ts +1 -0
- package/dist/math.d.ts.map +1 -1
- package/dist/math.js +17 -0
- package/dist/model.d.ts +9 -0
- package/dist/model.d.ts.map +1 -1
- package/dist/model.js +21 -3
- package/dist/vmd-loader.d.ts +16 -0
- package/dist/vmd-loader.d.ts.map +1 -1
- package/dist/vmd-loader.js +41 -0
- package/package.json +2 -2
- package/src/camera-animation.ts +93 -0
- package/src/camera.ts +49 -2
- package/src/engine.ts +118 -3
- package/src/index.ts +3 -0
- package/src/math.ts +17 -0
- package/src/model.ts +25 -3
- package/src/vmd-loader.ts +54 -0
package/src/camera.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { Mat4, Vec3 } from "./math"
|
|
1
|
+
import { Mat4, Quat, Vec3 } from "./math"
|
|
2
|
+
import type { CameraPose } from "./camera-animation"
|
|
2
3
|
|
|
3
4
|
/** Far cap / zoom limit; large enough for wide shots without clipping distant ground */
|
|
4
5
|
const FAR_CAP = 8000
|
|
@@ -41,6 +42,16 @@ export class Camera {
|
|
|
41
42
|
private _viewMat = new Mat4(new Float32Array(16))
|
|
42
43
|
private _projMat = new Mat4(new Float32Array(16))
|
|
43
44
|
|
|
45
|
+
// ── VMD camera drive ──
|
|
46
|
+
// When vmdDriven, getViewMatrix builds the shot from a sampled MMD camera pose (target /
|
|
47
|
+
// rotation-euler / distance / fov) instead of the orbit params. Toggled by the engine.
|
|
48
|
+
vmdDriven: boolean = false
|
|
49
|
+
private _vmdTarget = new Vec3(0, 0, 0)
|
|
50
|
+
private _vmdRotation = new Vec3(0, 0, 0) // euler radians
|
|
51
|
+
private _vmdDistance = -45
|
|
52
|
+
private _savedFov = Math.PI / 4
|
|
53
|
+
private _quatScratch = new Float32Array(16)
|
|
54
|
+
|
|
44
55
|
constructor(alpha: number, beta: number, radius: number, target: Vec3, fov: number = Math.PI / 4) {
|
|
45
56
|
this.alpha = alpha
|
|
46
57
|
this.beta = beta
|
|
@@ -68,7 +79,41 @@ export class Camera {
|
|
|
68
79
|
return new Vec3(x, y, z)
|
|
69
80
|
}
|
|
70
81
|
|
|
82
|
+
/** Enter/leave VMD-camera drive. Backs up the orbit fov on enter, restores it on leave
|
|
83
|
+
* (the VMD camera animates fov, so orbit's value would otherwise be clobbered). */
|
|
84
|
+
setVmdDriven(enabled: boolean): void {
|
|
85
|
+
if (enabled === this.vmdDriven) return
|
|
86
|
+
if (enabled) this._savedFov = this.fov
|
|
87
|
+
else this.fov = this._savedFov
|
|
88
|
+
this.vmdDriven = enabled
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Feed the next sampled MMD camera pose (engine calls this each frame while driving). */
|
|
92
|
+
setVmdPose(pose: CameraPose): void {
|
|
93
|
+
this._vmdTarget.set(pose.target)
|
|
94
|
+
this._vmdRotation.set(pose.rotation)
|
|
95
|
+
this._vmdDistance = pose.distance
|
|
96
|
+
this.fov = pose.fov // drives the projection
|
|
97
|
+
}
|
|
98
|
+
|
|
71
99
|
getViewMatrix(): Mat4 {
|
|
100
|
+
if (this.vmdDriven) {
|
|
101
|
+
// MMD camera: look at `target` from `distance` back, oriented by the euler rotation.
|
|
102
|
+
// forward = q·(0,0,1) (LH), up = q·(0,1,0) — read as columns 2 and 1 of the rot matrix.
|
|
103
|
+
// eye = target + forward·distance (distance is negative in VMD, so eye sits behind).
|
|
104
|
+
// Euler is NEGATED on all three axes: babylon-mmd builds the shot with
|
|
105
|
+
// RotationYawPitchRoll(-ry, -rx, -rz), and our fromEuler(rx,ry,rz) equals
|
|
106
|
+
// RotationYawPitchRoll(ry,rx,rz) — so the MMD-authentic rotation is fromEuler(-r).
|
|
107
|
+
const r = this._vmdRotation
|
|
108
|
+
const q = Quat.fromEuler(-r.x, -r.y, -r.z)
|
|
109
|
+
Mat4.fromQuatInto(q.x, q.y, q.z, q.w, this._quatScratch, 0)
|
|
110
|
+
const s = this._quatScratch
|
|
111
|
+
const t = this._vmdTarget
|
|
112
|
+
const d = this._vmdDistance
|
|
113
|
+
const ex = t.x + s[8] * d, ey = t.y + s[9] * d, ez = t.z + s[10] * d
|
|
114
|
+
Mat4.lookAtInto(this._viewMat.values, ex, ey, ez, t.x, t.y, t.z, s[4], s[5], s[6])
|
|
115
|
+
return this._viewMat
|
|
116
|
+
}
|
|
72
117
|
const eye = this.getPosition()
|
|
73
118
|
const t = this.target
|
|
74
119
|
Mat4.lookAtInto(this._viewMat.values, eye.x, eye.y, eye.z, t.x, t.y, t.z, 0, 1, 0)
|
|
@@ -200,7 +245,7 @@ export class Camera {
|
|
|
200
245
|
}
|
|
201
246
|
|
|
202
247
|
private onMouseMove(e: MouseEvent) {
|
|
203
|
-
if (this.inputLocked) return
|
|
248
|
+
if (this.inputLocked || this.vmdDriven) return // VMD owns the camera; orbit/pan is inert
|
|
204
249
|
if (!this.isDragging) return
|
|
205
250
|
|
|
206
251
|
const deltaX = e.clientX - this.lastMousePos.x
|
|
@@ -228,6 +273,7 @@ export class Camera {
|
|
|
228
273
|
|
|
229
274
|
private onWheel(e: WheelEvent) {
|
|
230
275
|
e.preventDefault()
|
|
276
|
+
if (this.vmdDriven) return // VMD owns the camera; zoom is inert
|
|
231
277
|
|
|
232
278
|
// Update camera radius (zoom)
|
|
233
279
|
this.radius += e.deltaY * this.wheelPrecision
|
|
@@ -274,6 +320,7 @@ export class Camera {
|
|
|
274
320
|
private onTouchMove(e: TouchEvent) {
|
|
275
321
|
if (this.inputLocked) return
|
|
276
322
|
e.preventDefault()
|
|
323
|
+
if (this.vmdDriven) return // VMD owns the camera; pinch/pan/rotate is inert
|
|
277
324
|
|
|
278
325
|
if (this.isPinching && e.touches.length === 2) {
|
|
279
326
|
// Two-finger gesture: can be pinch zoom or pan
|
package/src/engine.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { Mat4, Quat, Vec3 } from "./math"
|
|
|
3
3
|
import { Model, type Material } from "./model"
|
|
4
4
|
import { MORPH_COMPUTE_WGSL } from "./shaders/passes/morph"
|
|
5
5
|
import { decodeTga } from "./tga-loader"
|
|
6
|
+
import { VMDLoader } from "./vmd-loader"
|
|
7
|
+
import { CameraAnimation } from "./camera-animation"
|
|
6
8
|
import { PmxLoader } from "./pmx-loader"
|
|
7
9
|
import { RezePhysics } from "./physics"
|
|
8
10
|
import {
|
|
@@ -82,7 +84,7 @@ const PRESET_NAME_HINTS: Array<[MaterialPreset, string[]]> = [
|
|
|
82
84
|
],
|
|
83
85
|
["hair", ["前髪", "後髪", "髪", "髮", "头发", "頭髪", "もみあげ", "アホ毛", "ヘア", "hair", "ahoge", "bang"]],
|
|
84
86
|
["body", ["肌", "皮肤", "skin"]],
|
|
85
|
-
["metal", ["金属", "メタル", "metal"]],
|
|
87
|
+
["metal", ["金属", "メタル", "metal", "earring", "耳环", "耳環"]],
|
|
86
88
|
[
|
|
87
89
|
"cloth_smooth",
|
|
88
90
|
[
|
|
@@ -101,15 +103,20 @@ const PRESET_NAME_HINTS: Array<[MaterialPreset, string[]]> = [
|
|
|
101
103
|
"飾",
|
|
102
104
|
"饰",
|
|
103
105
|
"尾",
|
|
106
|
+
"套", // 外套 (coat), 手套 (gloves)
|
|
107
|
+
"腿", // 腿环 (leg ring/garter) and other leg-wear accessories
|
|
104
108
|
"skirt",
|
|
105
109
|
"dress",
|
|
106
110
|
"ribbon",
|
|
107
111
|
"sleeve",
|
|
108
112
|
"shoes",
|
|
113
|
+
"shirt",
|
|
114
|
+
"short", // shorts
|
|
109
115
|
"boot",
|
|
110
116
|
"hat",
|
|
111
117
|
"cloth",
|
|
112
118
|
"accessor",
|
|
119
|
+
"trigger",
|
|
113
120
|
],
|
|
114
121
|
],
|
|
115
122
|
]
|
|
@@ -197,6 +204,16 @@ export type WorldOptions = {
|
|
|
197
204
|
strength?: number
|
|
198
205
|
}
|
|
199
206
|
|
|
207
|
+
/** A model's scene placement — root offset baked into skinning + visibility. Serializable
|
|
208
|
+
* into a scene descriptor via getModelTransform. */
|
|
209
|
+
export type ModelTransform = {
|
|
210
|
+
position: Vec3
|
|
211
|
+
rotation: Quat
|
|
212
|
+
/** Uniform scale (default 1). */
|
|
213
|
+
scale: number
|
|
214
|
+
visible: boolean
|
|
215
|
+
}
|
|
216
|
+
|
|
200
217
|
export type SunOptions = {
|
|
201
218
|
/** Linear color of the sun lamp (Blender: Light > Color). */
|
|
202
219
|
color?: Vec3
|
|
@@ -606,6 +623,10 @@ export class Engine {
|
|
|
606
623
|
// GPU vertex-morph path. Set false BEFORE loadModel to fall back to the CPU path (A/B).
|
|
607
624
|
private useGpuMorphs = true
|
|
608
625
|
|
|
626
|
+
// VMD camera track (a dedicated camera VMD). When loaded + enabled it drives the shot,
|
|
627
|
+
// sampled off the animated model's clock so it stays synced to the dance.
|
|
628
|
+
private cameraAnimation: CameraAnimation | null = null
|
|
629
|
+
|
|
609
630
|
// Camera target binding (Babylon/Three style: camera follows model)
|
|
610
631
|
private cameraTargetModel: Model | null = null
|
|
611
632
|
private cameraTargetBoneName = "全ての親"
|
|
@@ -2161,6 +2182,59 @@ export class Engine {
|
|
|
2161
2182
|
this.cameraTargetOffset.z = offset?.z ?? 0
|
|
2162
2183
|
}
|
|
2163
2184
|
|
|
2185
|
+
// ── VMD camera track ──
|
|
2186
|
+
// A dedicated camera VMD (target / rotation / distance / fov animated). Motion VMDs loaded
|
|
2187
|
+
// via model.loadVmd never touch the camera — the camera shot is opt-in through here.
|
|
2188
|
+
|
|
2189
|
+
/** Load a camera VMD (dedicated camera file, or any VMD's camera block) and drive the shot
|
|
2190
|
+
* from it. Default-on once a non-empty track loads; toggle with setCameraVmdEnabled. */
|
|
2191
|
+
async loadCameraVmd(url: string): Promise<void> {
|
|
2192
|
+
const frames = await VMDLoader.loadCamera(url)
|
|
2193
|
+
this.cameraAnimation = frames.length ? new CameraAnimation(frames) : null
|
|
2194
|
+
this.camera.setVmdDriven(this.cameraAnimation !== null)
|
|
2195
|
+
}
|
|
2196
|
+
|
|
2197
|
+
/** Load a camera VMD from an already-fetched buffer (e.g. a File the user dropped). */
|
|
2198
|
+
loadCameraVmdFromBuffer(buffer: ArrayBuffer): void {
|
|
2199
|
+
const frames = VMDLoader.loadCameraFromBuffer(buffer)
|
|
2200
|
+
this.cameraAnimation = frames.length ? new CameraAnimation(frames) : null
|
|
2201
|
+
this.camera.setVmdDriven(this.cameraAnimation !== null)
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2204
|
+
/** Turn the loaded camera VMD on/off (falls back to orbit when off). No-op if none loaded. */
|
|
2205
|
+
setCameraVmdEnabled(enabled: boolean): void {
|
|
2206
|
+
this.camera.setVmdDriven(enabled && this.cameraAnimation !== null)
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
/** True while the loaded camera VMD is actively driving the shot. */
|
|
2210
|
+
isCameraVmdEnabled(): boolean {
|
|
2211
|
+
return this.camera.vmdDriven
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2214
|
+
/** True if a (non-empty) camera VMD is loaded, regardless of enabled state. */
|
|
2215
|
+
hasCameraVmd(): boolean {
|
|
2216
|
+
return this.cameraAnimation !== null
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
/** Drop the loaded camera VMD and return to orbit control. */
|
|
2220
|
+
clearCameraVmd(): void {
|
|
2221
|
+
this.cameraAnimation = null
|
|
2222
|
+
this.camera.setVmdDriven(false)
|
|
2223
|
+
}
|
|
2224
|
+
|
|
2225
|
+
// Clock the camera VMD runs on: the first model with an active clip (playing or scrubbed),
|
|
2226
|
+
// so a static stage in the scene never freezes the shot at frame 0. Falls back to the first
|
|
2227
|
+
// model, then to 0 (empty scene).
|
|
2228
|
+
private cameraClockTime(): number {
|
|
2229
|
+
let first: number | null = null
|
|
2230
|
+
for (const inst of this.modelInstances.values()) {
|
|
2231
|
+
const p = inst.model.getAnimationProgress()
|
|
2232
|
+
if (first === null) first = p.current
|
|
2233
|
+
if (p.playing || p.paused) return p.current
|
|
2234
|
+
}
|
|
2235
|
+
return first ?? 0
|
|
2236
|
+
}
|
|
2237
|
+
|
|
2164
2238
|
getCameraDistance(): number {
|
|
2165
2239
|
return this.camera.radius
|
|
2166
2240
|
}
|
|
@@ -2421,6 +2495,35 @@ export class Engine {
|
|
|
2421
2495
|
return this.modelInstances.get(name)?.model ?? null
|
|
2422
2496
|
}
|
|
2423
2497
|
|
|
2498
|
+
/**
|
|
2499
|
+
* Place a model in the scene — position, rotation, uniform scale, visibility. The
|
|
2500
|
+
* transform is a root offset baked into skinning (moves the whole rig), so it composes
|
|
2501
|
+
* with animation. Use it to sit a `stage.pmx` with a character, or to fit/hide either.
|
|
2502
|
+
* Scale is **uniform** (normals renormalize in-shader). Don't scale a physics-driven
|
|
2503
|
+
* character — its colliders won't scale; scale stages (which are typically physics-free).
|
|
2504
|
+
*/
|
|
2505
|
+
setModelTransform(name: string, transform: Partial<ModelTransform>): void {
|
|
2506
|
+
const model = this.modelInstances.get(name)?.model
|
|
2507
|
+
if (!model) return
|
|
2508
|
+
if (transform.position) model.setPosition(transform.position)
|
|
2509
|
+
if (transform.rotation) model.setRotation(transform.rotation)
|
|
2510
|
+
if (transform.scale !== undefined) model.setScale(transform.scale)
|
|
2511
|
+
if (transform.visible !== undefined) model.setVisible(transform.visible)
|
|
2512
|
+
}
|
|
2513
|
+
|
|
2514
|
+
/** Read a model's scene transform (for serialization into a scene descriptor). */
|
|
2515
|
+
getModelTransform(name: string): ModelTransform | null {
|
|
2516
|
+
const model = this.modelInstances.get(name)?.model
|
|
2517
|
+
if (!model) return null
|
|
2518
|
+
const p = model.position
|
|
2519
|
+
return {
|
|
2520
|
+
position: new Vec3(p.x, p.y, p.z),
|
|
2521
|
+
rotation: model.rotation.clone(),
|
|
2522
|
+
scale: model.scale,
|
|
2523
|
+
visible: model.visible,
|
|
2524
|
+
}
|
|
2525
|
+
}
|
|
2526
|
+
|
|
2424
2527
|
markVertexBufferDirty(modelNameOrModel?: string | Model): void {
|
|
2425
2528
|
if (modelNameOrModel === undefined) return
|
|
2426
2529
|
if (typeof modelNameOrModel === "string") {
|
|
@@ -3727,6 +3830,7 @@ export class Engine {
|
|
|
3727
3830
|
pass.setBindGroup(0, this.pickPerFrameBindGroup)
|
|
3728
3831
|
|
|
3729
3832
|
this.forEachInstance((inst) => {
|
|
3833
|
+
if (!inst.model.visible) return // hidden models aren't pickable
|
|
3730
3834
|
pass.setVertexBuffer(0, inst.vertexBuffer)
|
|
3731
3835
|
pass.setVertexBuffer(1, inst.jointsBuffer)
|
|
3732
3836
|
pass.setVertexBuffer(2, inst.weightsBuffer)
|
|
@@ -3828,6 +3932,12 @@ export class Engine {
|
|
|
3828
3932
|
}
|
|
3829
3933
|
}
|
|
3830
3934
|
|
|
3935
|
+
// Drive the shot from the camera VMD (synced to the animated model's clock).
|
|
3936
|
+
if (this.camera.vmdDriven && this.cameraAnimation) {
|
|
3937
|
+
const pose = this.cameraAnimation.sample(this.cameraClockTime())
|
|
3938
|
+
if (pose) this.camera.setVmdPose(pose)
|
|
3939
|
+
}
|
|
3940
|
+
|
|
3831
3941
|
this.updateCameraUniforms()
|
|
3832
3942
|
this.updateShadowLightVP()
|
|
3833
3943
|
|
|
@@ -3848,12 +3958,17 @@ export class Engine {
|
|
|
3848
3958
|
},
|
|
3849
3959
|
})
|
|
3850
3960
|
sp.setPipeline(this.shadowDepthPipeline)
|
|
3851
|
-
this.forEachInstance((inst) =>
|
|
3961
|
+
this.forEachInstance((inst) => {
|
|
3962
|
+
if (inst.model.visible) this.drawInstanceShadow(sp, inst)
|
|
3963
|
+
})
|
|
3852
3964
|
sp.end()
|
|
3853
3965
|
}
|
|
3854
3966
|
|
|
3855
3967
|
const pass = encoder.beginRenderPass(this.renderPassDescriptor)
|
|
3856
|
-
if (hasModels)
|
|
3968
|
+
if (hasModels)
|
|
3969
|
+
this.forEachInstance((inst) => {
|
|
3970
|
+
if (inst.model.visible) this.renderOneModel(pass, inst)
|
|
3971
|
+
})
|
|
3857
3972
|
if (this.hasGround) this.renderGround(pass)
|
|
3858
3973
|
pass.end()
|
|
3859
3974
|
|
package/src/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ export {
|
|
|
9
9
|
type LoadModelFromFilesOptions,
|
|
10
10
|
type MaterialPreset,
|
|
11
11
|
type MaterialPresetMap,
|
|
12
|
+
type ModelTransform,
|
|
12
13
|
type GizmoDragEvent,
|
|
13
14
|
type GizmoDragCallback,
|
|
14
15
|
type GizmoDragKind,
|
|
@@ -59,4 +60,6 @@ export type {
|
|
|
59
60
|
ControlPoint,
|
|
60
61
|
} from "./animation"
|
|
61
62
|
export { FPS } from "./animation"
|
|
63
|
+
export { VMDLoader, type CameraKeyframe } from "./vmd-loader"
|
|
64
|
+
export { CameraAnimation, type CameraPose } from "./camera-animation"
|
|
62
65
|
export { RezePhysics } from "./physics"
|
package/src/math.ts
CHANGED
|
@@ -535,6 +535,23 @@ export class Mat4 {
|
|
|
535
535
|
out[14] = pz
|
|
536
536
|
}
|
|
537
537
|
|
|
538
|
+
// Position + rotation + UNIFORM scale. Scales the rotation basis (columns 0–2) by s;
|
|
539
|
+
// uniform scale keeps normals correct after the shader's normalize() (no inverse-transpose).
|
|
540
|
+
static fromPositionRotationScaleInto(
|
|
541
|
+
px: number, py: number, pz: number,
|
|
542
|
+
qx: number, qy: number, qz: number, qw: number,
|
|
543
|
+
s: number,
|
|
544
|
+
out: Float32Array
|
|
545
|
+
): void {
|
|
546
|
+
Mat4.fromQuatInto(qx, qy, qz, qw, out, 0)
|
|
547
|
+
out[0] *= s; out[1] *= s; out[2] *= s
|
|
548
|
+
out[4] *= s; out[5] *= s; out[6] *= s
|
|
549
|
+
out[8] *= s; out[9] *= s; out[10] *= s
|
|
550
|
+
out[12] = px
|
|
551
|
+
out[13] = py
|
|
552
|
+
out[14] = pz
|
|
553
|
+
}
|
|
554
|
+
|
|
538
555
|
// In-place 4x4 inverse into out array. Returns true on success, false if singular (out untouched).
|
|
539
556
|
static inverseInto(m: Float32Array, out: Float32Array): boolean {
|
|
540
557
|
const a00 = m[0], a01 = m[1], a02 = m[2], a03 = m[3]
|
package/src/model.ts
CHANGED
|
@@ -199,6 +199,17 @@ export class Model {
|
|
|
199
199
|
return this._rotation
|
|
200
200
|
}
|
|
201
201
|
|
|
202
|
+
/** Uniform scale (default 1). Used to fit a stage.pmx to the character. */
|
|
203
|
+
get scale(): number {
|
|
204
|
+
return this._scale
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Whether this model renders. Hidden models skip the main, shadow, and pick passes;
|
|
208
|
+
* physics keeps running so they resume consistently. */
|
|
209
|
+
get visible(): boolean {
|
|
210
|
+
return this._visible
|
|
211
|
+
}
|
|
212
|
+
|
|
202
213
|
setPosition(position: Vec3): void {
|
|
203
214
|
this._position.set(position)
|
|
204
215
|
this.rootMatrixDirty = true
|
|
@@ -209,6 +220,15 @@ export class Model {
|
|
|
209
220
|
this.rootMatrixDirty = true
|
|
210
221
|
}
|
|
211
222
|
|
|
223
|
+
setScale(scale: number): void {
|
|
224
|
+
this._scale = scale
|
|
225
|
+
this.rootMatrixDirty = true
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
setVisible(visible: boolean): void {
|
|
229
|
+
this._visible = visible
|
|
230
|
+
}
|
|
231
|
+
|
|
212
232
|
private vertexData: Float32Array<ArrayBuffer>
|
|
213
233
|
private baseVertexData: Float32Array<ArrayBuffer> // Original vertex data before morphing
|
|
214
234
|
private vertexCount: number
|
|
@@ -254,6 +274,8 @@ export class Model {
|
|
|
254
274
|
// Skip-when-identity flag avoids the extra mat mul per bone when unused.
|
|
255
275
|
private _position: Vec3 = Vec3.zeros()
|
|
256
276
|
private _rotation: Quat = Quat.identity()
|
|
277
|
+
private _scale: number = 1
|
|
278
|
+
private _visible: boolean = true
|
|
257
279
|
private rootMatrixValues: Float32Array = new Float32Array([1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1])
|
|
258
280
|
private rootMatrixDirty: boolean = false
|
|
259
281
|
private rootIsIdentity: boolean = true
|
|
@@ -851,11 +873,11 @@ export class Model {
|
|
|
851
873
|
|
|
852
874
|
// Rebuild root matrix + cache identity-shortcut flag only when pos/rot changed.
|
|
853
875
|
if (this.rootMatrixDirty) {
|
|
854
|
-
const p = this._position, r = this._rotation
|
|
855
|
-
Mat4.
|
|
876
|
+
const p = this._position, r = this._rotation, s = this._scale
|
|
877
|
+
Mat4.fromPositionRotationScaleInto(p.x, p.y, p.z, r.x, r.y, r.z, r.w, s, this.rootMatrixValues)
|
|
856
878
|
this.rootIsIdentity =
|
|
857
879
|
p.x === 0 && p.y === 0 && p.z === 0 &&
|
|
858
|
-
r.x === 0 && r.y === 0 && r.z === 0 && r.w === 1
|
|
880
|
+
r.x === 0 && r.y === 0 && r.z === 0 && r.w === 1 && s === 1
|
|
859
881
|
this.rootMatrixDirty = false
|
|
860
882
|
}
|
|
861
883
|
|
package/src/vmd-loader.ts
CHANGED
|
@@ -20,6 +20,18 @@ export interface VMDKeyFrame {
|
|
|
20
20
|
morphFrames: MorphFrame[]
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/** One MMD camera keyframe. The camera looks at `target` from `distance` away, oriented by
|
|
24
|
+
* `rotation` (euler radians), with `fov` degrees. `interpolation` is 24 bytes: 6 channels
|
|
25
|
+
* (posX, posY, posZ, rotation, distance, fov) × 4 bezier bytes (x1, x2, y1, y2). */
|
|
26
|
+
export interface CameraKeyframe {
|
|
27
|
+
frame: number
|
|
28
|
+
distance: number
|
|
29
|
+
target: Vec3
|
|
30
|
+
rotation: Vec3 // euler radians
|
|
31
|
+
fov: number // degrees
|
|
32
|
+
interpolation: Uint8Array // 24 bytes
|
|
33
|
+
}
|
|
34
|
+
|
|
23
35
|
export class VMDLoader {
|
|
24
36
|
private view: DataView
|
|
25
37
|
private offset = 0
|
|
@@ -46,6 +58,48 @@ export class VMDLoader {
|
|
|
46
58
|
return loader.parse()
|
|
47
59
|
}
|
|
48
60
|
|
|
61
|
+
/** Parse only the camera track (a dedicated camera VMD, or the camera block of any VMD).
|
|
62
|
+
* Returns [] if the file has no camera block (motion-only VMDs end after the morph block). */
|
|
63
|
+
static async loadCamera(url: string): Promise<CameraKeyframe[]> {
|
|
64
|
+
const loader = new VMDLoader(await fetch(url).then((r) => r.arrayBuffer()))
|
|
65
|
+
return loader.parseCamera()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
static loadCameraFromBuffer(buffer: ArrayBuffer): CameraKeyframe[] {
|
|
69
|
+
return new VMDLoader(buffer).parseCamera()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Seek past the bone + morph blocks (fixed record sizes) to the camera block, then read it.
|
|
73
|
+
// bone frame = 111 B (15 name + 4 frame + 12 pos + 16 rot + 64 interp); morph = 23 B
|
|
74
|
+
// (15 name + 4 frame + 4 weight); camera = 61 B.
|
|
75
|
+
private parseCamera(): CameraKeyframe[] {
|
|
76
|
+
this.offset = 0
|
|
77
|
+
const header = this.getString(30)
|
|
78
|
+
if (!header.startsWith("Vocaloid Motion Data")) throw new Error("Invalid VMD file header")
|
|
79
|
+
this.skip(20)
|
|
80
|
+
const boneCount = this.getUint32()
|
|
81
|
+
this.skip(boneCount * 111)
|
|
82
|
+
const morphCount = this.getUint32()
|
|
83
|
+
this.skip(morphCount * 23)
|
|
84
|
+
if (this.offset + 4 > this.view.buffer.byteLength) return [] // no camera block
|
|
85
|
+
const cameraCount = this.getUint32()
|
|
86
|
+
|
|
87
|
+
const frames: CameraKeyframe[] = []
|
|
88
|
+
for (let i = 0; i < cameraCount; i++) {
|
|
89
|
+
const frame = this.getUint32()
|
|
90
|
+
const distance = this.getFloat32()
|
|
91
|
+
const target = new Vec3(this.getFloat32(), this.getFloat32(), this.getFloat32())
|
|
92
|
+
const rotation = new Vec3(this.getFloat32(), this.getFloat32(), this.getFloat32())
|
|
93
|
+
const interpolation = new Uint8Array(24)
|
|
94
|
+
for (let j = 0; j < 24; j++) interpolation[j] = this.getUint8()
|
|
95
|
+
const fov = this.getUint32()
|
|
96
|
+
this.skip(1) // perspective flag (0 = perspective) — unused
|
|
97
|
+
frames.push({ frame, distance, target, rotation, fov, interpolation })
|
|
98
|
+
}
|
|
99
|
+
frames.sort((a, b) => a.frame - b.frame)
|
|
100
|
+
return frames
|
|
101
|
+
}
|
|
102
|
+
|
|
49
103
|
private parse(): VMDKeyFrame[] {
|
|
50
104
|
// Read header (30 bytes)
|
|
51
105
|
const header = this.getString(30)
|