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/README.md +18 -3
- package/dist/animation.d.ts +18 -0
- package/dist/animation.d.ts.map +1 -1
- package/dist/animation.js +26 -16
- package/dist/dds-loader.d.ts +12 -0
- package/dist/dds-loader.d.ts.map +1 -0
- package/dist/dds-loader.js +246 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +72 -15
- package/dist/model.d.ts +47 -0
- package/dist/model.d.ts.map +1 -1
- package/dist/model.js +107 -22
- package/dist/pmx-loader.d.ts.map +1 -1
- package/dist/pmx-loader.js +8 -5
- package/dist/psd-loader.d.ts +13 -0
- package/dist/psd-loader.d.ts.map +1 -0
- package/dist/psd-loader.js +192 -0
- package/package.json +1 -1
- package/src/animation.ts +26 -16
- package/src/dds-loader.ts +236 -0
- package/src/engine.ts +78 -22
- package/src/model.ts +116 -22
- package/src/pmx-loader.ts +10 -5
- package/src/psd-loader.ts +193 -0
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3053
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
4623
|
-
|
|
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).
|
|
4627
|
-
//
|
|
4628
|
-
//
|
|
4629
|
-
//
|
|
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 matter — a 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
|
|
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
|