reze-engine 0.41.1 → 0.41.3

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.
@@ -0,0 +1,192 @@
1
+ // PSD → RGBA8, from the merged composite.
2
+ //
3
+ // MMD texture packs are often shipped as the artist's working files, so a
4
+ // material's texture path points at a .psd that no browser will decode. The
5
+ // layers are none of our business — Photoshop writes a flattened composite of
6
+ // the whole document at the end of the file, which is exactly the image the
7
+ // artist saw, so that is what this reads.
8
+ //
9
+ // (A file saved with "Maximize Compatibility" off has no useful composite. There
10
+ // is nothing to be done about that here beyond failing clearly: reconstructing it
11
+ // would mean compositing every layer, blend mode and clipping group — a Photoshop,
12
+ // not a texture loader.)
13
+ const MAGIC = 0x38425053; // "8BPS", big-endian — PSD is big-endian throughout
14
+ var ColorMode;
15
+ (function (ColorMode) {
16
+ ColorMode[ColorMode["Bitmap"] = 0] = "Bitmap";
17
+ ColorMode[ColorMode["Grayscale"] = 1] = "Grayscale";
18
+ ColorMode[ColorMode["Indexed"] = 2] = "Indexed";
19
+ ColorMode[ColorMode["RGB"] = 3] = "RGB";
20
+ ColorMode[ColorMode["CMYK"] = 4] = "CMYK";
21
+ ColorMode[ColorMode["Multichannel"] = 7] = "Multichannel";
22
+ ColorMode[ColorMode["Duotone"] = 8] = "Duotone";
23
+ ColorMode[ColorMode["Lab"] = 9] = "Lab";
24
+ })(ColorMode || (ColorMode = {}));
25
+ const MODE_NAMES = {
26
+ [ColorMode.Bitmap]: "bitmap",
27
+ [ColorMode.CMYK]: "CMYK",
28
+ [ColorMode.Multichannel]: "multichannel",
29
+ [ColorMode.Lab]: "Lab",
30
+ };
31
+ /** True when these bytes are a PSD/PSB, by magic rather than by file extension. */
32
+ export function isPsd(buffer) {
33
+ return buffer.byteLength >= 26 && new DataView(buffer).getUint32(0, false) === MAGIC;
34
+ }
35
+ /**
36
+ * PackBits, one row at a time.
37
+ *
38
+ * `end` bounds the row rather than the buffer: the row-length table says how many
39
+ * compressed bytes this row occupies, and trusting the control bytes past that
40
+ * would let one malformed row eat the next one's data.
41
+ */
42
+ function unpackBits(src, start, end, dst, at, limit) {
43
+ let i = start;
44
+ let o = at;
45
+ while (i < end && o < limit) {
46
+ const n = (src[i++] << 24) >> 24; // to signed
47
+ if (n >= 0) {
48
+ const count = Math.min(n + 1, limit - o, end - i);
49
+ for (let k = 0; k < count; k++)
50
+ dst[o++] = src[i++];
51
+ }
52
+ else if (n !== -128) {
53
+ // -128 is a no-op by the spec, not a run of 129.
54
+ const count = Math.min(1 - n, limit - o);
55
+ const b = src[i++];
56
+ for (let k = 0; k < count; k++)
57
+ dst[o++] = b;
58
+ }
59
+ }
60
+ return o;
61
+ }
62
+ /**
63
+ * Decode a PSD's composite to RGBA8.
64
+ *
65
+ * RGB, grayscale, indexed and duotone at 8 or 16 bits per channel, raw or RLE —
66
+ * which is every texture that has actually turned up. CMYK and Lab throw: they
67
+ * need a colour conversion that would be guesswork without a profile, and a
68
+ * silently wrong-coloured texture is worse than a missing one.
69
+ */
70
+ export function decodePsd(buffer) {
71
+ const v = new DataView(buffer);
72
+ if (buffer.byteLength < 26 || v.getUint32(0, false) !== MAGIC)
73
+ throw new Error("not a PSD");
74
+ const version = v.getUint16(4, false); // 1 = PSD, 2 = PSB
75
+ if (version !== 1 && version !== 2)
76
+ throw new Error(`PSD unsupported version ${version}`);
77
+ const channels = v.getUint16(12, false);
78
+ const height = v.getUint32(14, false);
79
+ const width = v.getUint32(18, false);
80
+ const depth = v.getUint16(22, false);
81
+ const mode = v.getUint16(24, false);
82
+ if (width <= 0 || height <= 0)
83
+ throw new Error(`PSD bad dimensions ${width}x${height}`);
84
+ if (depth !== 8 && depth !== 16)
85
+ throw new Error(`PSD unsupported bit depth ${depth}`);
86
+ if (mode in MODE_NAMES)
87
+ throw new Error(`PSD unsupported colour mode: ${MODE_NAMES[mode]}`);
88
+ // Three variable-length sections stand between the header and the pixels. Only
89
+ // the indexed palette is worth reading; the rest is skipped by its length.
90
+ let p = 26;
91
+ const colorDataLen = v.getUint32(p, false);
92
+ const colorData = p + 4;
93
+ p = colorData + colorDataLen;
94
+ p += 4 + v.getUint32(p, false); // image resources
95
+ // PSB states this length in 8 bytes. Only the low word can matter — the high
96
+ // one would mean a layer section larger than 4GB.
97
+ if (version === 2) {
98
+ p += 8 + v.getUint32(p + 4, false);
99
+ }
100
+ else {
101
+ p += 4 + v.getUint32(p, false);
102
+ }
103
+ if (p + 2 > buffer.byteLength)
104
+ throw new Error("PSD truncated before the composite");
105
+ const compression = v.getUint16(p, false);
106
+ p += 2;
107
+ // Only the channels that carry colour, plus one alpha. A PSD can hold spot and
108
+ // mask channels past those; they are stored after, so ignoring them is a matter
109
+ // of not reading that far.
110
+ const colourChannels = mode === ColorMode.RGB ? 3 : 1;
111
+ const hasAlpha = channels > colourChannels;
112
+ const used = colourChannels + (hasAlpha ? 1 : 0);
113
+ if (channels < colourChannels)
114
+ throw new Error(`PSD has ${channels} channel(s), expected ${colourChannels}`);
115
+ const bytesPerSample = depth === 16 ? 2 : 1;
116
+ const planeSamples = width * height;
117
+ const planeBytes = planeSamples * bytesPerSample;
118
+ const planes = new Uint8Array(used * planeBytes);
119
+ if (compression === 0) {
120
+ const need = used * planeBytes;
121
+ if (p + need > buffer.byteLength)
122
+ throw new Error("PSD truncated composite");
123
+ planes.set(new Uint8Array(buffer, p, need));
124
+ }
125
+ else if (compression === 1) {
126
+ // A table of per-row compressed lengths for EVERY channel comes first,
127
+ // including the channels being skipped — so the table is read in full even
128
+ // though only the first `used` channels' rows are decoded.
129
+ const countBytes = version === 2 ? 4 : 2;
130
+ const tableBytes = channels * height * countBytes;
131
+ if (p + tableBytes > buffer.byteLength)
132
+ throw new Error("PSD truncated row table");
133
+ const rowLengths = new Uint32Array(channels * height);
134
+ for (let i = 0; i < rowLengths.length; i++) {
135
+ rowLengths[i] = countBytes === 2 ? v.getUint16(p + i * 2, false) : v.getUint32(p + i * 4, false);
136
+ }
137
+ const src = new Uint8Array(buffer);
138
+ let at = p + tableBytes;
139
+ for (let c = 0; c < channels; c++) {
140
+ for (let y = 0; y < height; y++) {
141
+ const len = rowLengths[c * height + y];
142
+ if (at + len > buffer.byteLength)
143
+ throw new Error("PSD truncated composite");
144
+ if (c < used) {
145
+ const rowStart = c * planeBytes + y * width * bytesPerSample;
146
+ unpackBits(src, at, at + len, planes, rowStart, rowStart + width * bytesPerSample);
147
+ }
148
+ at += len;
149
+ }
150
+ }
151
+ }
152
+ else {
153
+ // 2 and 3 are the Zip codes. They appear on layer data, effectively never on
154
+ // the composite, and inflating would mean shipping a decompressor.
155
+ throw new Error(`PSD unsupported compression ${compression}`);
156
+ }
157
+ // 16-bit samples are big-endian; the high byte is the 8-bit value.
158
+ const sample = (plane, i) => depth === 16 ? planes[plane * planeBytes + i * 2] : planes[plane * planeBytes + i];
159
+ const rgba = new Uint8Array(planeSamples * 4);
160
+ if (mode === ColorMode.Indexed) {
161
+ // The palette is the colour mode data: 256 reds, then 256 greens, then blues.
162
+ if (colorDataLen < 768)
163
+ throw new Error("PSD indexed image has no palette");
164
+ const pal = new Uint8Array(buffer, colorData, 768);
165
+ for (let i = 0; i < planeSamples; i++) {
166
+ const idx = sample(0, i);
167
+ rgba[i * 4] = pal[idx];
168
+ rgba[i * 4 + 1] = pal[256 + idx];
169
+ rgba[i * 4 + 2] = pal[512 + idx];
170
+ rgba[i * 4 + 3] = hasAlpha ? sample(1, i) : 255;
171
+ }
172
+ return { rgba, width, height };
173
+ }
174
+ for (let i = 0; i < planeSamples; i++) {
175
+ if (mode === ColorMode.RGB) {
176
+ rgba[i * 4] = sample(0, i);
177
+ rgba[i * 4 + 1] = sample(1, i);
178
+ rgba[i * 4 + 2] = sample(2, i);
179
+ }
180
+ else {
181
+ // Grayscale and duotone: one plane across all three. Duotone's ink colours
182
+ // live in the colour mode data, and the spec's own advice is to treat the
183
+ // data as grayscale — which is what every other reader does.
184
+ const g = sample(0, i);
185
+ rgba[i * 4] = g;
186
+ rgba[i * 4 + 1] = g;
187
+ rgba[i * 4 + 2] = g;
188
+ }
189
+ rgba[i * 4 + 3] = hasAlpha ? sample(colourChannels, i) : 255;
190
+ }
191
+ return { rgba, width, height };
192
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"composite.d.ts","sourceRoot":"","sources":["../../../src/shaders/passes/composite.ts"],"names":[],"mappings":"AAeA;;;;;;;;;;;;;;;;;;;;;;;;+BAwB+B;AAC/B,MAAM,MAAM,qBAAqB,GAAG;IAClC,gFAAgF;IAChF,IAAI,EAAE,MAAM,CAAA;IACZ,kFAAkF;IAClF,UAAU,EAAE,MAAM,CAAA;IAClB,4DAA4D;IAC5D,aAAa,EAAE,OAAO,CAAA;IACtB,oEAAoE;IACpE,aAAa,EAAE,OAAO,CAAA;CACvB,CAAA;AA0WD,wBAAgB,oBAAoB,CAAC,MAAM,CAAC,EAAE,qBAAqB,GAAG,IAAI,GAAG,MAAM,CAclF;AAED,iFAAiF;AACjF,eAAO,MAAM,qBAAqB,QAA6B,CAAA"}
1
+ {"version":3,"file":"composite.d.ts","sourceRoot":"","sources":["../../../src/shaders/passes/composite.ts"],"names":[],"mappings":"AAeA;;;;;;;;;;;;;;;;;;;;;;;;+BAwB+B;AAC/B,MAAM,MAAM,qBAAqB,GAAG;IAClC,gFAAgF;IAChF,IAAI,EAAE,MAAM,CAAA;IACZ,kFAAkF;IAClF,UAAU,EAAE,MAAM,CAAA;IAClB,4DAA4D;IAC5D,aAAa,EAAE,OAAO,CAAA;IACtB,oEAAoE;IACpE,aAAa,EAAE,OAAO,CAAA;CACvB,CAAA;AA4XD,wBAAgB,oBAAoB,CAAC,MAAM,CAAC,EAAE,qBAAqB,GAAG,IAAI,GAAG,MAAM,CAclF;AAED,iFAAiF;AACjF,eAAO,MAAM,qBAAqB,QAA6B,CAAA"}
@@ -23,7 +23,7 @@ override APPLY_GAMMA: bool = true;
23
23
  @group(0) @binding(0) var hdrTex: texture_2d<f32>;
24
24
  @group(0) @binding(1) var bloomTex: texture_2d<f32>; // bloomUpTexture mip 0 (full pyramid top)
25
25
  @group(0) @binding(2) var bloomSamp: sampler;
26
- @group(0) @binding(3) var<uniform> viewU: array<vec4<f32>, 11>;
26
+ @group(0) @binding(3) var<uniform> viewU: array<vec4<f32>, 15>;
27
27
  // Aux mask/alpha texture. .r = bloom mask (unused here; bloom blit uses it).
28
28
  // .g = accumulated canvas alpha (what hdr.a carried before the HDR format
29
29
  // became rg11b10ufloat). We unpremultiply HDR by this alpha for tonemap, then
@@ -171,6 +171,24 @@ fn bgResolution() -> vec2f { return viewU[6].zw; }
171
171
  /** The camera's world position. */
172
172
  fn bgCameraPos() -> vec3f { return viewU[10].xyz; }
173
173
 
174
+ /** How many characters are in the scene, up to four. */
175
+ fn bgSubjectCount() -> i32 { return i32(viewU[10].w); }
176
+
177
+ /**
178
+ * Where a character is standing, in world space.
179
+ *
180
+ * An effect that wants to RESPOND to the cast — ripples under the feet, a glow
181
+ * that follows someone, dust kicked up where they are — needs to know where
182
+ * they are, and the ray and the depth cannot tell it: they describe the pixel,
183
+ * not the scene. This is the model's root, which for a PMX is between the feet
184
+ * on the floor, so it is already the contact point a ripple wants.
185
+ *
186
+ * Clamped rather than bounds-checked: an effect looping past the count reads the
187
+ * last subject instead of sampling whatever follows the array, which is a wrong
188
+ * ripple rather than an undefined one.
189
+ */
190
+ fn bgSubjectPos(i: i32) -> vec3f { return viewU[11 + clamp(i, 0, 3)].xyz; }
191
+
174
192
  /** Where in the WORLD the scene drew this pixel — the depth handed to
175
193
  * foreground() turned into a place. Without it an effect can only think in
176
194
  * distances from the lens, which is no use to anything that belongs somewhere:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reze-engine",
3
- "version": "0.41.1",
3
+ "version": "0.41.3",
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",
@@ -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"
@@ -232,6 +234,14 @@ export type WorldOptions = {
232
234
 
233
235
  /** A model's scene placement — root offset baked into skinning + visibility. Serializable
234
236
  * into a scene descriptor via getModelTransform. */
237
+ /** How many character positions an effect can read (viewU[11..14]). */
238
+ const MAX_EFFECT_SUBJECTS = 4
239
+ /** Where a character IS, for an effect that follows them. センター carries a
240
+ * motion's root movement — walking, jumping — where the model transform only
241
+ * carries where the model was placed; 全ての親 is the fallback for a model that
242
+ * animates the true root instead. */
243
+ const SUBJECT_BONES = ["センター", "全ての親"]
244
+
235
245
  export type ModelTransform = {
236
246
  position: Vec3
237
247
  rotation: Quat
@@ -652,7 +662,9 @@ export class Engine {
652
662
  private device!: GPUDevice
653
663
  private context!: GPUCanvasContext
654
664
  private presentationFormat!: GPUTextureFormat
655
- private camera!: Camera
665
+ // No `!`: the constructor assigns it, so the type is the guarantee. Every other
666
+ // `!` field here is genuinely absent until init() — this one no longer is.
667
+ private camera: Camera
656
668
  private cameraUniformBuffer!: GPUBuffer
657
669
  private cameraMatrixData = new Float32Array(36)
658
670
  // Blender-style scene config groups (resolved from EngineOptions)
@@ -802,7 +814,7 @@ export class Engine {
802
814
  // 11 × vec4f — see the viewU comment in composite.ts. The last one is the
803
815
  // camera's world position, which is what lets a foreground effect turn the
804
816
  // depth it is handed into a PLACE (bgWorldPos) rather than a distance.
805
- private readonly compositeUniformData = new Float32Array(44)
817
+ private readonly compositeUniformData = new Float32Array(60)
806
818
  /** Composite background (display-space sRGB 0–1) — null = transparent canvas. */
807
819
  private backgroundColor: Vec3 | null = null
808
820
  // 360 backdrop (equirectangular skybox, sampled by view ray in composite).
@@ -979,6 +991,20 @@ export class Engine {
979
991
  target: options?.camera?.target ?? d.camera.target,
980
992
  fov: options?.camera?.fov ?? d.camera.fov,
981
993
  }
994
+ // Built HERE and not in setupCamera, because a host holds the Engine before
995
+ // init() resolves — the reference is assigned, then init is awaited — and it
996
+ // reads the camera in that window. isCameraVmdEnabled() on a camera that did
997
+ // not exist yet threw "Cannot read properties of undefined (reading
998
+ // 'vmdDriven')", which surfaces as the whole page failing to load. The Camera
999
+ // is pure math, so nothing about it needed the device; only its aspect and
1000
+ // its input listeners do, and those still wait for a sized canvas.
1001
+ this.camera = new Camera(
1002
+ Math.PI,
1003
+ Math.PI / 2.5,
1004
+ this.cameraConfig.distance,
1005
+ this.cameraConfig.target,
1006
+ this.cameraConfig.fov,
1007
+ )
982
1008
  this.onRaycast = options?.onRaycast
983
1009
  this.onGizmoDrag = options?.onGizmoDrag
984
1010
  this.bloomSettings = Engine.mergeBloomDefaults(options?.bloom)
@@ -2412,8 +2438,9 @@ export class Engine {
2412
2438
  // (bg rgb, mode) · camera right/up/forward basis for the 360 skybox ray ·
2413
2439
  // (time, _, canvas width, canvas height) for user effects · three grade
2414
2440
  // vectors (CDL offset+contrast, power+saturation, slope+flag) · camera
2415
- // world position, for an effect placing itself in the scene.
2416
- size: 176,
2441
+ // world position, for an effect placing itself in the scene · four
2442
+ // character positions, for one that wants to respond to the cast.
2443
+ size: 240,
2417
2444
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
2418
2445
  })
2419
2446
  this.dofUniformBuffer = this.device.createBuffer({
@@ -3040,14 +3067,8 @@ export class Engine {
3040
3067
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
3041
3068
  })
3042
3069
 
3043
- this.camera = new Camera(
3044
- Math.PI,
3045
- Math.PI / 2.5,
3046
- this.cameraConfig.distance,
3047
- this.cameraConfig.target,
3048
- this.cameraConfig.fov,
3049
- )
3050
-
3070
+ // The camera came up with the engine (see the constructor). What waits for
3071
+ // init is only what needs a device and a sized canvas.
3051
3072
  this.camera.aspect = this.canvas.width / this.canvas.height
3052
3073
  this.camera.attachControl(this.canvas)
3053
3074
  }
@@ -4614,21 +4635,28 @@ export class Engine {
4614
4635
  return null
4615
4636
  }
4616
4637
 
4617
- // Decode to either an ImageBitmap (web-native formats) or raw RGBA (TGA). .tga skips
4618
- // straight to the TGA decoder (createImageBitmap can't read it); other extensions try
4619
- // the browser decoder first, then fall back to TGA in case a .spa/.sph/etc. is TGA
4620
- // under its extension. Every failure is logged and soft never throws to the caller.
4638
+ // Decode to either an ImageBitmap (web-native formats) or raw RGBA (TGA, DDS, PSD).
4639
+ //
4640
+ // DDS and PSD are recognised by their MAGIC rather than their extension, because
4641
+ // the extension lies often enough to mattera converted stage's .tga is
4642
+ // sometimes a DDS, and a repacked texture folder is full of .png that never
4643
+ // stopped being Photoshop files. TGA has no magic to key on, so .tga skips
4644
+ // straight to its decoder (createImageBitmap can't read it) and every other
4645
+ // extension tries the browser first, then falls back to TGA in case a
4646
+ // .spa/.sph/etc. is TGA underneath. Every failure is logged and soft — this
4647
+ // never throws to the caller; the material just gets the white texture.
4621
4648
  let source: ImageBitmap | null = null
4622
4649
  let rgba: Uint8Array | null = null
4623
4650
  let width: number
4624
4651
  let height: number
4625
4652
 
4653
+ const cpuDecoder = isDds(buffer) ? decodeDds : isPsd(buffer) ? decodePsd : null
4626
4654
  const isTga = logicalPath.toLowerCase().endsWith(".tga")
4627
- if (!isTga) {
4655
+ if (!isTga && !cpuDecoder) {
4628
4656
  try {
4629
4657
  source = await createImageBitmap(new Blob([buffer]), { premultiplyAlpha: "none", colorSpaceConversion: "none" })
4630
4658
  } catch {
4631
- source = null // not a browser-native image — try TGA below
4659
+ source = null // not a browser-native image — try the CPU decoders below
4632
4660
  }
4633
4661
  }
4634
4662
 
@@ -4637,7 +4665,7 @@ export class Engine {
4637
4665
  height = source.height
4638
4666
  } else {
4639
4667
  try {
4640
- const img = decodeTga(buffer)
4668
+ const img = (cpuDecoder ?? decodeTga)(buffer)
4641
4669
  rgba = img.rgba
4642
4670
  width = img.width
4643
4671
  height = img.height
@@ -6122,6 +6150,40 @@ export class Engine {
6122
6150
  u[40] = cameraPos.x
6123
6151
  u[41] = cameraPos.y
6124
6152
  u[42] = cameraPos.z
6153
+ // Character positions (viewU[11..14]), count in viewU[10].w. Stages are
6154
+ // excluded: an effect asking where the cast is means the characters, and a
6155
+ // stage's origin is wherever its author put it, which is not a place
6156
+ // anything is standing. Four is the cap because the uniform is small and a
6157
+ // scene with five characters is not the case this serves.
6158
+ let n = 0
6159
+ this.forEachInstance((inst) => {
6160
+ if (n >= MAX_EFFECT_SUBJECTS || inst.isStage) return
6161
+ const m = inst.model
6162
+ // The model transform is only where the model was PLACED. A motion moves
6163
+ // the character by animating bones, so an effect anchored to the
6164
+ // transform never follows anyone anywhere — it sits at the spawn point
6165
+ // while they walk out of it. Composed exactly as the follow camera
6166
+ // composes it, for the same reason: bone matrices are model-space.
6167
+ let px = m.position.x
6168
+ let py = m.position.y
6169
+ let pz = m.position.z
6170
+ for (const bone of SUBJECT_BONES) {
6171
+ const pos = m.getBoneWorldPosition(bone)
6172
+ if (!pos) continue
6173
+ const sc = m.scale
6174
+ pos.setXYZ(pos.x * sc, pos.y * sc, pos.z * sc)
6175
+ Quat.rotateVecInto(m.rotation, pos, pos)
6176
+ px += pos.x
6177
+ py += pos.y
6178
+ pz += pos.z
6179
+ break
6180
+ }
6181
+ u[44 + n * 4] = px
6182
+ u[45 + n * 4] = py
6183
+ u[46 + n * 4] = pz
6184
+ n++
6185
+ })
6186
+ u[43] = n
6125
6187
  this.device.queue.writeBuffer(this.compositeUniformBuffer, 0, u)
6126
6188
  }
6127
6189
  }