reze-engine 0.41.2 → 0.41.4

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/src/animation.ts CHANGED
@@ -355,24 +355,34 @@ export function bezierInterpolate(x1: number, x2: number, y1: number, y2: number
355
355
 
356
356
  const INV_127 = 1 / 127
357
357
 
358
+ /**
359
+ * The 64 interpolation bytes of a VMD bone frame, as four bezier curves.
360
+ *
361
+ * MMD interleaves the channels rather than storing them one after another: byte
362
+ * `i` is channel i's x1, `i + 4` its y1, `i + 8` its x2, `i + 12` its y2, where
363
+ * the channels are X = 0, Y = 1, Z = 2 and ROTATION = 3. The remaining 48 bytes
364
+ * are the same record written three more times, each shifted a byte left — a
365
+ * legacy quirk, and not one to read from: real files in this repo disagree with
366
+ * their own shifted copies, so the first block is the only trustworthy one.
367
+ *
368
+ * Rotation used to read `raw[0..3]`, which is not rotation's curve at all — it
369
+ * is the x1 byte of all four channels in a row. On an ordinary keyframe that
370
+ * evaluates to a bezier with both control points at y = 0: the curve holds near
371
+ * zero for most of the interval and then snaps to 1 at its end. Applied to every
372
+ * bone's rotation on every keyframe interval, which is essentially all of an MMD
373
+ * motion, it reads as a dance that stutters between poses instead of flowing
374
+ * through them.
375
+ */
358
376
  export function rawInterpolationToBoneInterpolation(raw: Uint8Array): BoneInterpolation {
377
+ const channel = (i: number): ControlPoint[] => [
378
+ { x: raw[i], y: raw[i + 4] },
379
+ { x: raw[i + 8], y: raw[i + 12] },
380
+ ]
359
381
  return {
360
- rotation: [
361
- { x: raw[0], y: raw[2] },
362
- { x: raw[1], y: raw[3] },
363
- ],
364
- translationX: [
365
- { x: raw[0], y: raw[4] },
366
- { x: raw[8], y: raw[12] },
367
- ],
368
- translationY: [
369
- { x: raw[16], y: raw[20] },
370
- { x: raw[24], y: raw[28] },
371
- ],
372
- translationZ: [
373
- { x: raw[32], y: raw[36] },
374
- { x: raw[40], y: raw[44] },
375
- ],
382
+ translationX: channel(0),
383
+ translationY: channel(1),
384
+ translationZ: channel(2),
385
+ rotation: channel(3),
376
386
  }
377
387
  }
378
388
 
@@ -0,0 +1,236 @@
1
+ // DDS → RGBA8, decoded on the CPU.
2
+ //
3
+ // Stage models converted out of games ship their textures as DDS far more often
4
+ // than MMD character models do, and the browser cannot read one: createImageBitmap
5
+ // refuses it, and the TGA fallback reads the header as garbage and reports a
6
+ // height of zero. Every such material fell back to white.
7
+ //
8
+ // Decoded rather than uploaded as-is on purpose. WebGPU can sample BC formats
9
+ // natively, but only where the texture-compression-bc feature was requested at
10
+ // device creation — it is absent on much mobile hardware, and requesting a
11
+ // feature that may not exist to read a file that may not appear is a worse trade
12
+ // than spending a few milliseconds per texture at load.
13
+
14
+ import type { DecodedImage } from "./tga-loader"
15
+
16
+ const MAGIC = 0x20534444 // "DDS "
17
+ const fourCC = (s: string) => s.charCodeAt(0) | (s.charCodeAt(1) << 8) | (s.charCodeAt(2) << 16) | (s.charCodeAt(3) << 24)
18
+ const FOURCC_DXT1 = fourCC("DXT1")
19
+ const FOURCC_DXT3 = fourCC("DXT3")
20
+ const FOURCC_DXT5 = fourCC("DXT5")
21
+ const FOURCC_DX10 = fourCC("DX10")
22
+
23
+ // The DXGI formats worth answering. BC1/2/3 are what a converted stage carries;
24
+ // the two RGBA8 spellings turn up in tools that write a DX10 header for an
25
+ // uncompressed surface.
26
+ const DXGI_BC1 = new Set([70, 71, 72])
27
+ const DXGI_BC2 = new Set([73, 74, 75])
28
+ const DXGI_BC3 = new Set([76, 77, 78])
29
+ const DXGI_RGBA8 = new Set([27, 28, 29])
30
+ const DXGI_BGRA8 = new Set([87, 88, 91])
31
+
32
+ /** True when these bytes are a DDS, by magic rather than by file extension. */
33
+ export function isDds(buffer: ArrayBuffer): boolean {
34
+ return buffer.byteLength >= 128 && new DataView(buffer).getUint32(0, true) === MAGIC
35
+ }
36
+
37
+ function rgb565(c: number, out: Uint8Array, o: number): void {
38
+ const r = (c >> 11) & 0x1f
39
+ const g = (c >> 5) & 0x3f
40
+ const b = c & 0x1f
41
+ // Bit-replication, not a shift: (r << 3) leaves white at 248 and tints every
42
+ // bright surface, which reads as a dull texture rather than a decode bug.
43
+ out[o] = (r << 3) | (r >> 2)
44
+ out[o + 1] = (g << 2) | (g >> 4)
45
+ out[o + 2] = (b << 3) | (b >> 2)
46
+ out[o + 3] = 255
47
+ }
48
+
49
+ /**
50
+ * One BC1 colour block into `dst`.
51
+ *
52
+ * `opaque` forces the four-colour mode. BC1 on its own picks its mode per block
53
+ * from the endpoint order — c0 <= c1 means three colours and a transparent
54
+ * fourth — but when a BC1 block is the colour half of a BC2/BC3 pair the alpha
55
+ * lives in the other half and the four-colour mode is unconditional.
56
+ */
57
+ function bc1Block(v: DataView, off: number, dst: Uint8Array, x0: number, y0: number, w: number, h: number, opaque: boolean): void {
58
+ const c0 = v.getUint16(off, true)
59
+ const c1 = v.getUint16(off + 2, true)
60
+ const bits = v.getUint32(off + 4, true)
61
+ const pal = new Uint8Array(16)
62
+ rgb565(c0, pal, 0)
63
+ rgb565(c1, pal, 4)
64
+ if (c0 > c1 || opaque) {
65
+ for (let i = 0; i < 3; i++) {
66
+ pal[8 + i] = (2 * pal[i] + pal[4 + i] + 1) / 3
67
+ pal[12 + i] = (pal[i] + 2 * pal[4 + i] + 1) / 3
68
+ }
69
+ pal[11] = 255
70
+ pal[15] = 255
71
+ } else {
72
+ for (let i = 0; i < 3; i++) pal[8 + i] = (pal[i] + pal[4 + i]) >> 1
73
+ pal[11] = 255
74
+ // The fourth entry is transparent black in this mode — the 1-bit alpha.
75
+ pal[12] = 0
76
+ pal[13] = 0
77
+ pal[14] = 0
78
+ pal[15] = 0
79
+ }
80
+ for (let py = 0; py < 4; py++) {
81
+ for (let px = 0; px < 4; px++) {
82
+ const x = x0 + px
83
+ const y = y0 + py
84
+ if (x >= w || y >= h) continue
85
+ const idx = ((bits >> (2 * (4 * py + px))) & 3) * 4
86
+ const o = (y * w + x) * 4
87
+ dst[o] = pal[idx]
88
+ dst[o + 1] = pal[idx + 1]
89
+ dst[o + 2] = pal[idx + 2]
90
+ dst[o + 3] = pal[idx + 3]
91
+ }
92
+ }
93
+ }
94
+
95
+ /** BC3's 3-bit interpolated alpha block. */
96
+ function bc3Alpha(v: DataView, off: number, dst: Uint8Array, x0: number, y0: number, w: number, h: number): void {
97
+ const a0 = v.getUint8(off)
98
+ const a1 = v.getUint8(off + 1)
99
+ const a = new Uint8Array(8)
100
+ a[0] = a0
101
+ a[1] = a1
102
+ if (a0 > a1) {
103
+ for (let i = 1; i < 7; i++) a[i + 1] = ((7 - i) * a0 + i * a1) / 7
104
+ } else {
105
+ for (let i = 1; i < 5; i++) a[i + 1] = ((5 - i) * a0 + i * a1) / 5
106
+ a[6] = 0
107
+ a[7] = 255
108
+ }
109
+ // 16 three-bit indices over six bytes; read as two 24-bit halves so the
110
+ // shifts stay inside the 32-bit range JS bit ops actually work in.
111
+ const lo = v.getUint8(off + 2) | (v.getUint8(off + 3) << 8) | (v.getUint8(off + 4) << 16)
112
+ const hi = v.getUint8(off + 5) | (v.getUint8(off + 6) << 8) | (v.getUint8(off + 7) << 16)
113
+ for (let i = 0; i < 16; i++) {
114
+ const x = x0 + (i & 3)
115
+ const y = y0 + (i >> 2)
116
+ if (x >= w || y >= h) continue
117
+ const bitsFor = i < 8 ? (lo >> (3 * i)) & 7 : (hi >> (3 * (i - 8))) & 7
118
+ dst[(y * w + x) * 4 + 3] = a[bitsFor]
119
+ }
120
+ }
121
+
122
+ /** BC2's 4-bit explicit alpha block. */
123
+ function bc2Alpha(v: DataView, off: number, dst: Uint8Array, x0: number, y0: number, w: number, h: number): void {
124
+ for (let i = 0; i < 16; i++) {
125
+ const x = x0 + (i & 3)
126
+ const y = y0 + (i >> 2)
127
+ if (x >= w || y >= h) continue
128
+ const nib = v.getUint8(off + (i >> 1))
129
+ const a = i & 1 ? nib >> 4 : nib & 0x0f
130
+ dst[(y * w + x) * 4 + 3] = (a << 4) | a
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Decode the top mip of a DDS to RGBA8.
136
+ *
137
+ * Only the first surface: the engine generates its own mip chain, and a cube map
138
+ * or an array reaching here is a texture slot that was never going to mean what
139
+ * the file meant anyway.
140
+ */
141
+ export function decodeDds(buffer: ArrayBuffer): DecodedImage {
142
+ const v = new DataView(buffer)
143
+ if (buffer.byteLength < 128 || v.getUint32(0, true) !== MAGIC) throw new Error("not a DDS")
144
+
145
+ const headerFlags = v.getUint32(8, true)
146
+ const height = v.getUint32(12, true)
147
+ const width = v.getUint32(16, true)
148
+ const pitch = v.getUint32(20, true)
149
+ if (width <= 0 || height <= 0) throw new Error(`DDS bad dimensions ${width}x${height}`)
150
+
151
+ const pfFlags = v.getUint32(80, true)
152
+ const pfFourCC = v.getUint32(84, true)
153
+ const rgbBits = v.getUint32(88, true)
154
+ const rMask = v.getUint32(92, true)
155
+ const aMask = v.getUint32(104, true)
156
+
157
+ let data = 128
158
+ let kind: "bc1" | "bc2" | "bc3" | "rgba" | "bgra" | null = null
159
+
160
+ if (pfFlags & 0x4) {
161
+ if (pfFourCC === FOURCC_DXT1) kind = "bc1"
162
+ else if (pfFourCC === FOURCC_DXT3) kind = "bc2"
163
+ else if (pfFourCC === FOURCC_DXT5) kind = "bc3"
164
+ else if (pfFourCC === FOURCC_DX10) {
165
+ const dxgi = v.getUint32(128, true)
166
+ data = 148 // 128 header + 20-byte DX10 extension
167
+ if (DXGI_BC1.has(dxgi)) kind = "bc1"
168
+ else if (DXGI_BC2.has(dxgi)) kind = "bc2"
169
+ else if (DXGI_BC3.has(dxgi)) kind = "bc3"
170
+ else if (DXGI_RGBA8.has(dxgi)) kind = "rgba"
171
+ else if (DXGI_BGRA8.has(dxgi)) kind = "bgra"
172
+ else throw new Error(`DDS unsupported DXGI format ${dxgi}`)
173
+ } else {
174
+ throw new Error(`DDS unsupported fourCC 0x${pfFourCC.toString(16)}`)
175
+ }
176
+ } else if (pfFlags & 0x40 && (rgbBits === 32 || rgbBits === 24)) {
177
+ // Uncompressed. The masks are stated over the little-endian DWORD, so the
178
+ // channel owning the LOW byte is the one stored first: red low is RGBA,
179
+ // and the classic D3D A8R8G8B8 (red at 0x00ff0000) is BGRA in memory.
180
+ kind = rMask === 0x000000ff ? "rgba" : "bgra"
181
+ }
182
+ if (!kind) throw new Error("DDS unsupported pixel format")
183
+
184
+ const rgba = new Uint8Array(width * height * 4)
185
+
186
+ if (kind === "rgba" || kind === "bgra") {
187
+ const bytes = rgbBits === 24 ? 3 : 4
188
+ const hasAlpha = bytes === 4 && aMask !== 0
189
+ // Rows can be padded, and the header says by how much — DDSD_PITCH means the
190
+ // pitch field is a byte stride rather than a total size. Reading past it
191
+ // shears the image diagonally, which looks like a corrupt texture.
192
+ const stride = headerFlags & 0x8 && pitch >= width * bytes ? pitch : width * bytes
193
+ if (data + stride * height > buffer.byteLength) throw new Error("DDS truncated")
194
+ const src = new Uint8Array(buffer, data)
195
+ for (let y = 0; y < height; y++) {
196
+ for (let x = 0; x < width; x++) {
197
+ const s = y * stride + x * bytes
198
+ const o = (y * width + x) * 4
199
+ if (kind === "bgra") {
200
+ rgba[o] = src[s + 2]
201
+ rgba[o + 1] = src[s + 1]
202
+ rgba[o + 2] = src[s]
203
+ } else {
204
+ rgba[o] = src[s]
205
+ rgba[o + 1] = src[s + 1]
206
+ rgba[o + 2] = src[s + 2]
207
+ }
208
+ rgba[o + 3] = hasAlpha ? src[s + 3] : 255
209
+ }
210
+ }
211
+ return { rgba, width, height }
212
+ }
213
+
214
+ const blockBytes = kind === "bc1" ? 8 : 16
215
+ const bw = Math.max(1, (width + 3) >> 2)
216
+ const bh = Math.max(1, (height + 3) >> 2)
217
+ if (data + bw * bh * blockBytes > buffer.byteLength) throw new Error("DDS truncated")
218
+
219
+ for (let by = 0; by < bh; by++) {
220
+ for (let bx = 0; bx < bw; bx++) {
221
+ const off = data + (by * bw + bx) * blockBytes
222
+ const x0 = bx * 4
223
+ const y0 = by * 4
224
+ if (kind === "bc1") {
225
+ bc1Block(v, off, rgba, x0, y0, width, height, false)
226
+ } else {
227
+ // Colour first so the alpha block can overwrite what BC1 wrote — in
228
+ // BC2/BC3 the colour half is always the opaque four-colour mode.
229
+ bc1Block(v, off + 8, rgba, x0, y0, width, height, true)
230
+ if (kind === "bc2") bc2Alpha(v, off, rgba, x0, y0, width, height)
231
+ else bc3Alpha(v, off, rgba, x0, y0, width, height)
232
+ }
233
+ }
234
+ }
235
+ return { rgba, width, height }
236
+ }
package/src/engine.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { Camera } from "./camera"
2
+ import { decodeDds, isDds } from "./dds-loader"
2
3
  import { Mat4, Quat, Vec3 } from "./math"
4
+ import { decodePsd, isPsd } from "./psd-loader"
3
5
  import { Model, MATERIAL_MORPH_MULTIPLY, type Material } from "./model"
4
6
  import { MORPH_COMPUTE_WGSL } from "./shaders/passes/morph"
5
7
  import { decodeTga } from "./tga-loader"
@@ -646,6 +648,9 @@ function materialAlphaStats(
646
648
  return { avg: sum / n / 255, translucentFrac: translucent / n }
647
649
  }
648
650
 
651
+ /** Tried in order when a PMX names a texture without an extension. */
652
+ const TEXTURE_EXTENSION_GUESSES = [".png", ".jpg", ".jpeg", ".bmp", ".tga", ".dds", ".spa", ".sph"]
653
+
649
654
  export class Engine {
650
655
  private static instance: Engine | null = null
651
656
 
@@ -660,7 +665,9 @@ export class Engine {
660
665
  private device!: GPUDevice
661
666
  private context!: GPUCanvasContext
662
667
  private presentationFormat!: GPUTextureFormat
663
- private camera!: Camera
668
+ // No `!`: the constructor assigns it, so the type is the guarantee. Every other
669
+ // `!` field here is genuinely absent until init() — this one no longer is.
670
+ private camera: Camera
664
671
  private cameraUniformBuffer!: GPUBuffer
665
672
  private cameraMatrixData = new Float32Array(36)
666
673
  // Blender-style scene config groups (resolved from EngineOptions)
@@ -987,6 +994,20 @@ export class Engine {
987
994
  target: options?.camera?.target ?? d.camera.target,
988
995
  fov: options?.camera?.fov ?? d.camera.fov,
989
996
  }
997
+ // Built HERE and not in setupCamera, because a host holds the Engine before
998
+ // init() resolves — the reference is assigned, then init is awaited — and it
999
+ // reads the camera in that window. isCameraVmdEnabled() on a camera that did
1000
+ // not exist yet threw "Cannot read properties of undefined (reading
1001
+ // 'vmdDriven')", which surfaces as the whole page failing to load. The Camera
1002
+ // is pure math, so nothing about it needed the device; only its aspect and
1003
+ // its input listeners do, and those still wait for a sized canvas.
1004
+ this.camera = new Camera(
1005
+ Math.PI,
1006
+ Math.PI / 2.5,
1007
+ this.cameraConfig.distance,
1008
+ this.cameraConfig.target,
1009
+ this.cameraConfig.fov,
1010
+ )
990
1011
  this.onRaycast = options?.onRaycast
991
1012
  this.onGizmoDrag = options?.onGizmoDrag
992
1013
  this.bloomSettings = Engine.mergeBloomDefaults(options?.bloom)
@@ -3049,14 +3070,8 @@ export class Engine {
3049
3070
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
3050
3071
  })
3051
3072
 
3052
- this.camera = new Camera(
3053
- Math.PI,
3054
- Math.PI / 2.5,
3055
- this.cameraConfig.distance,
3056
- this.cameraConfig.target,
3057
- this.cameraConfig.fov,
3058
- )
3059
-
3073
+ // The camera came up with the engine (see the constructor). What waits for
3074
+ // init is only what needs a device and a sized canvas.
3060
3075
  this.camera.aspect = this.canvas.width / this.canvas.height
3061
3076
  this.camera.attachControl(this.canvas)
3062
3077
  }
@@ -3162,13 +3177,21 @@ export class Engine {
3162
3177
  // so a static stage in the scene never freezes the shot at frame 0. Falls back to the first
3163
3178
  // model, then to 0 (empty scene).
3164
3179
  private cameraClockTime(): number {
3165
- let first: number | null = null
3180
+ let fallback: number | null = null
3166
3181
  for (const inst of this.modelInstances.values()) {
3182
+ // Stages are skipped outright. Scenery carries no motion, and it is added
3183
+ // BEFORE the cast — it paints while the models stream in behind it — so it
3184
+ // is first in insertion order and was seeding this clock with its own
3185
+ // permanent zero. In a scene with a stage, a camera VMD therefore sampled
3186
+ // frame 0 forever and the shot never moved.
3187
+ if (inst.isStage) continue
3167
3188
  const p = inst.model.getAnimationProgress()
3168
- if (first === null) first = p.current
3169
3189
  if (p.playing || p.paused) return p.current
3190
+ // Otherwise the first cast member that actually HAS a clip: one still at
3191
+ // bind pose must not claim the clock from one holding the motion.
3192
+ if (fallback === null && p.duration > 0) fallback = p.current
3170
3193
  }
3171
- return first ?? 0
3194
+ return fallback ?? 0
3172
3195
  }
3173
3196
 
3174
3197
  /** Current orbit eye position (spherical coords resolved to a point). */
@@ -4615,29 +4638,62 @@ export class Engine {
4615
4638
  return cached
4616
4639
  }
4617
4640
 
4618
- let buffer: ArrayBuffer
4641
+ // PMX texture tables are hand-maintained, and they routinely carry entries
4642
+ // that are not files. Two kinds show up constantly: a bare directory
4643
+ // ("Textures", "spa\\"), which is a leftover placeholder pointing at nothing,
4644
+ // and a name whose extension was dropped — where the texture is sitting right
4645
+ // there on disk one suffix longer, and the material renders white for want of
4646
+ // it. The first is answered by staying quiet, the second by trying.
4647
+ let buffer: ArrayBuffer | null = null
4648
+ let readError: unknown = null
4619
4649
  try {
4620
4650
  buffer = await inst.assetReader.readBinary(logicalPath)
4621
4651
  } catch (e) {
4622
- console.warn(`[reze] texture read failed: ${logicalPath}`, e instanceof Error ? e.message : e)
4623
- return null
4652
+ readError = e
4653
+ }
4654
+ if (!buffer) {
4655
+ const base = logicalPath.split(/[\\/]/).pop() ?? ""
4656
+ // No basename at all: the entry named a directory. Nothing was ever meant
4657
+ // to load, so this is not a failure worth a line in anyone's console.
4658
+ if (!base) return null
4659
+ if (!base.includes(".")) {
4660
+ for (const ext of TEXTURE_EXTENSION_GUESSES) {
4661
+ try {
4662
+ buffer = await inst.assetReader.readBinary(`${logicalPath}${ext}`)
4663
+ break
4664
+ } catch {
4665
+ // keep trying — the list is short and only runs for a broken entry
4666
+ }
4667
+ }
4668
+ }
4669
+ if (!buffer) {
4670
+ console.warn(`[reze] texture read failed: ${logicalPath}`, readError instanceof Error ? readError.message : readError)
4671
+ return null
4672
+ }
4624
4673
  }
4625
4674
 
4626
- // Decode to either an ImageBitmap (web-native formats) or raw RGBA (TGA). .tga skips
4627
- // straight to the TGA decoder (createImageBitmap can't read it); other extensions try
4628
- // the browser decoder first, then fall back to TGA in case a .spa/.sph/etc. is TGA
4629
- // under its extension. Every failure is logged and soft never throws to the caller.
4675
+ // Decode to either an ImageBitmap (web-native formats) or raw RGBA (TGA, DDS, PSD).
4676
+ //
4677
+ // DDS and PSD are recognised by their MAGIC rather than their extension, because
4678
+ // the extension lies often enough to mattera converted stage's .tga is
4679
+ // sometimes a DDS, and a repacked texture folder is full of .png that never
4680
+ // stopped being Photoshop files. TGA has no magic to key on, so .tga skips
4681
+ // straight to its decoder (createImageBitmap can't read it) and every other
4682
+ // extension tries the browser first, then falls back to TGA in case a
4683
+ // .spa/.sph/etc. is TGA underneath. Every failure is logged and soft — this
4684
+ // never throws to the caller; the material just gets the white texture.
4630
4685
  let source: ImageBitmap | null = null
4631
4686
  let rgba: Uint8Array | null = null
4632
4687
  let width: number
4633
4688
  let height: number
4634
4689
 
4690
+ const cpuDecoder = isDds(buffer) ? decodeDds : isPsd(buffer) ? decodePsd : null
4635
4691
  const isTga = logicalPath.toLowerCase().endsWith(".tga")
4636
- if (!isTga) {
4692
+ if (!isTga && !cpuDecoder) {
4637
4693
  try {
4638
4694
  source = await createImageBitmap(new Blob([buffer]), { premultiplyAlpha: "none", colorSpaceConversion: "none" })
4639
4695
  } catch {
4640
- source = null // not a browser-native image — try TGA below
4696
+ source = null // not a browser-native image — try the CPU decoders below
4641
4697
  }
4642
4698
  }
4643
4699
 
@@ -4646,7 +4702,7 @@ export class Engine {
4646
4702
  height = source.height
4647
4703
  } else {
4648
4704
  try {
4649
- const img = decodeTga(buffer)
4705
+ const img = (cpuDecoder ?? decodeTga)(buffer)
4650
4706
  rgba = img.rgba
4651
4707
  width = img.width
4652
4708
  height = img.height