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/model.ts CHANGED
@@ -92,6 +92,12 @@ export interface Bone {
92
92
  appendRatio?: number // 0..1
93
93
  appendRotate?: boolean
94
94
  appendMove?: boolean
95
+ /** 軸制限: this bone rotates ONLY about this axis (the twist bones use it). */
96
+ fixedAxis?: [number, number, number]
97
+ /** 変形階層: MMD poses whole layers in order, not bones in array order.
98
+ * Parsed but not yet honoured — distinct from Model's `deformOrder`, which is
99
+ * this engine's parent-before-child traversal. */
100
+ deformLayer?: number
95
101
  ikTargetIndex?: number // IK target bone index (if this bone is an IK effector)
96
102
  ikIteration?: number // IK iteration count
97
103
  ikLimitAngle?: number // IK rotation constraint (radians)
@@ -477,6 +483,9 @@ export class Model {
477
483
  this.applyMorphs()
478
484
  }
479
485
 
486
+ /** 軸制限 bones and their normalised axes — see applyFixedAxes. */
487
+ private fixedAxisBones: { index: number; x: number; y: number; z: number }[] = []
488
+
480
489
  private initializeRuntimeSkeleton(): void {
481
490
  const boneCount = this.skeleton.bones.length
482
491
 
@@ -490,6 +499,18 @@ export class Model {
490
499
  worldMatrices[i] = Mat4.identity()
491
500
  }
492
501
 
502
+ // 軸制限 bones, gathered once and normalised. A standard rig has four —
503
+ // the two arm twists and the two wrist twists — so this is a four-element
504
+ // walk per pose rather than a branch on every bone.
505
+ this.fixedAxisBones = []
506
+ for (let i = 0; i < boneCount; i++) {
507
+ const a = this.skeleton.bones[i].fixedAxis
508
+ if (!a) continue
509
+ const len = Math.hypot(a[0], a[1], a[2])
510
+ if (len < 1e-8) continue
511
+ this.fixedAxisBones.push({ index: i, x: a[0] / len, y: a[1] / len, z: a[2] / len })
512
+ }
513
+
493
514
  this.runtimeSkeleton = {
494
515
  localRotations,
495
516
  localTranslations,
@@ -673,19 +694,42 @@ export class Model {
673
694
  * Stages are the reason this exists: a door or a lift is rigged as a bone
674
695
  * morph and there is no VMD anywhere that drives it.
675
696
  */
697
+ /**
698
+ * Take the last frame's bone-morph offsets back off, before any pose source
699
+ * runs.
700
+ *
701
+ * This cannot live at the top of applyBoneMorphs, which is where it used to
702
+ * be. A pose source writes only the bones its clip keys, and it writes them
703
+ * from the clip — so restoring afterwards overwrote the freshly animated pose
704
+ * with a snapshot taken a frame earlier, and the re-snapshot immediately
705
+ * below then saved that same stale value again. The result was not a drift or
706
+ * a lag: every bone any bone morph touched stayed pinned to the first frame
707
+ * the model was posed at, permanently, and at ANY weight — the restore ran
708
+ * over the touched set before the weight test, so a morph sitting at 0 froze
709
+ * its bones just as hard as one at 1.
710
+ *
711
+ * It reads as "the arms don't animate" because arms are what these morphs
712
+ * touch: character models routinely ship T-Pose / A-Pose / ShouderBlend /
713
+ * ElbowBlend adjusters on 腕 and ひじ, and nothing else in the rig is a
714
+ * comparably popular bone-morph target.
715
+ */
716
+ private undoBoneMorphs(): void {
717
+ if (!this.boneMorphApplied) return
718
+ for (let i = 0; i < this.boneMorphBones.length; i++) {
719
+ const b = this.boneMorphBones[i]
720
+ this.runtimeSkeleton.localRotations[b].set(this.boneMorphRestoreR[i])
721
+ this.runtimeSkeleton.localTranslations[b].set(this.boneMorphRestoreT[i])
722
+ }
723
+ this.boneMorphApplied = false
724
+ }
725
+
676
726
  private applyBoneMorphs(): void {
677
727
  if (this.boneMorphPlan.length === 0) return
678
728
 
679
- // Undo the previous application first. With a clip running this is a no-op
680
- // in effect the pose source already overwrote these bones but a stage
681
- // has no clip, so without it the same offset would be re-added every frame.
682
- if (this.boneMorphApplied) {
683
- for (let i = 0; i < this.boneMorphBones.length; i++) {
684
- const b = this.boneMorphBones[i]
685
- this.runtimeSkeleton.localRotations[b].set(this.boneMorphRestoreR[i])
686
- this.runtimeSkeleton.localTranslations[b].set(this.boneMorphRestoreT[i])
687
- }
688
- }
729
+ // Snapshot the pose as the sources left it, so undoBoneMorphs can hand these
730
+ // bones back unmorphed at the top of the next frame. The two halves are a
731
+ // pair: without the undo a stage which has no clip to rewrite its bones —
732
+ // would compound the same offset every frame.
689
733
  for (let i = 0; i < this.boneMorphBones.length; i++) {
690
734
  const b = this.boneMorphBones[i]
691
735
  this.boneMorphRestoreR[i].set(this.runtimeSkeleton.localRotations[b])
@@ -1922,6 +1966,39 @@ export class Model {
1922
1966
  : frameA.weight
1923
1967
  }
1924
1968
 
1969
+ /**
1970
+ * Hold 軸制限 bones to their own axis.
1971
+ *
1972
+ * A twist bone (腕捩 / 手捩) exists to rotate about the length of the limb and
1973
+ * nothing else, and PMX says so by giving it a fixed axis. A VMD still keys it
1974
+ * with a full quaternion, so without the constraint the bone bends as well as
1975
+ * twists — and because the elbow is parented to 腕捩 and 腕捩1..3 inherit a
1976
+ * fraction of it, that error is carried into the whole forearm and multiplied
1977
+ * three ways down the arm. It is the most visible thing on the model.
1978
+ *
1979
+ * Projecting the quaternion's vector part onto the axis and renormalising is
1980
+ * what keeps the twist and drops everything else; the same operation MMD and
1981
+ * every faithful runtime performs.
1982
+ *
1983
+ * Applied to the STORED local rotation rather than inside a matrix path,
1984
+ * because the append children read that array directly: constrain it once and
1985
+ * the bone, its inheritors and a hand-posed gizmo all agree.
1986
+ */
1987
+ private applyFixedAxes(): void {
1988
+ for (const a of this.fixedAxisBones) {
1989
+ const q = this.runtimeSkeleton.localRotations[a.index]
1990
+ const dot = q.x * a.x + q.y * a.y + q.z * a.z
1991
+ const x = a.x * dot
1992
+ const y = a.y * dot
1993
+ const z = a.z * dot
1994
+ const len = Math.sqrt(x * x + y * y + z * z + q.w * q.w)
1995
+ if (len > 1e-8) {
1996
+ const inv = 1 / len
1997
+ q.setXYZW(x * inv, y * inv, z * inv, q.w * inv)
1998
+ }
1999
+ }
2000
+ }
2001
+
1925
2002
  private applyPoseFromClip(clip: AnimationClip | null, frame: number): void {
1926
2003
  if (!clip) return
1927
2004
  this.applyIkFromClip(clip, frame)
@@ -1941,6 +2018,7 @@ export class Model {
1941
2018
  this.runtimeSkeleton.localRotations[boneIdx].set(_animSlerp)
1942
2019
  this.runtimeSkeleton.localTranslations[boneIdx].set(localTranslation)
1943
2020
  }
2021
+ this.applyFixedAxes()
1944
2022
 
1945
2023
  for (const [morphName, keyFrames] of clip.morphTracks.entries()) {
1946
2024
  const weight = this.sampleMorphTrack(morphName, keyFrames, frame, this.morphTrackIndices)
@@ -2084,6 +2162,7 @@ export class Model {
2084
2162
  this.runtimeSkeleton.localRotations[i].set(_blendQ)
2085
2163
  this.runtimeSkeleton.localTranslations[i].set(localTranslation)
2086
2164
  }
2165
+ this.applyFixedAxes()
2087
2166
 
2088
2167
  const morphCount = morphAcc.length
2089
2168
  for (let i = 0; i < morphCount; i++) {
@@ -2237,6 +2316,13 @@ export class Model {
2237
2316
  const evWatch = this.clipEvents.size > 0 ? this.animationState.getCurrentAnimation() : null
2238
2317
  const evPrevFrame = evWatch !== null ? this.animationState.getCurrentFrame() : 0
2239
2318
  this.animationState.update(deltaTime)
2319
+
2320
+ // Hand the pose sources their bones unmorphed. A bone morph is an offset on
2321
+ // top of the pose, so last frame's offset has to come off before this
2322
+ // frame's pose goes on — taking it off afterwards is what froze every
2323
+ // morph-touched bone at its first posed frame.
2324
+ this.undoBoneMorphs()
2325
+
2240
2326
  if (!this.clipApplySuspended) {
2241
2327
  if (this.oneShot !== null) {
2242
2328
  this.applyOneShot(deltaTime)
@@ -2378,7 +2464,9 @@ export class Model {
2378
2464
 
2379
2465
  // Handle append transformations (same logic as computeWorldMatrices)
2380
2466
  const appendParentIdx = b.appendParentIndex
2381
- const hasAppend = b.appendRotate &&
2467
+ // `|| appendMove`: a move-only append (no rotation) was skipped altogether,
2468
+ // because the guard demanded appendRotate before either branch could run.
2469
+ const hasAppend = (b.appendRotate || b.appendMove) &&
2382
2470
  appendParentIdx !== undefined &&
2383
2471
  appendParentIdx >= 0 &&
2384
2472
  appendParentIdx < bones.length
@@ -2405,12 +2493,15 @@ export class Model {
2405
2493
  scratchQuat[2].setIdentity()
2406
2494
  Quat.slerpInto(scratchQuat[2], scratchQuat[0], absRatio, scratchQuat[1])
2407
2495
 
2408
- // finalRot = slerpResult * finalRot (rotation composition as quat mul)
2496
+ // MMD composes the append on the RIGHT: own animated rotation first, then
2497
+ // the inherited one (saba's `r = r * appendRotate`). We had it on the
2498
+ // left, which is a different rotation whenever a bone carries BOTH its
2499
+ // own key and an append — the twist bones, if a motion keys them.
2409
2500
  const sx = scratchQuat[1].x, sy = scratchQuat[1].y, sz = scratchQuat[1].z, sw = scratchQuat[1].w
2410
- const nx = sw * fx + sx * fw + sy * fz - sz * fy
2411
- const ny = sw * fy - sx * fz + sy * fw + sz * fx
2412
- const nz = sw * fz + sx * fy - sy * fx + sz * fw
2413
- const nw = sw * fw - sx * fx - sy * fy - sz * fz
2501
+ const nx = fw * sx + fx * sw + fy * sz - fz * sy
2502
+ const ny = fw * sy - fx * sz + fy * sw + fz * sx
2503
+ const nz = fw * sz + fx * sy - fy * sx + fz * sw
2504
+ const nw = fw * sw - fx * sx - fy * sy - fz * sz
2414
2505
  fx = nx; fy = ny; fz = nz; fw = nw
2415
2506
  }
2416
2507
 
@@ -2469,7 +2560,7 @@ export class Model {
2469
2560
 
2470
2561
  const appendParentIdx = b.appendParentIndex
2471
2562
  const hasAppend =
2472
- b.appendRotate && appendParentIdx !== undefined && appendParentIdx >= 0 && appendParentIdx < boneCount
2563
+ (b.appendRotate || b.appendMove) && appendParentIdx !== undefined && appendParentIdx >= 0 && appendParentIdx < boneCount
2473
2564
 
2474
2565
  if (hasAppend) {
2475
2566
  const ratio = b.appendRatio === undefined ? 1 : Math.max(-1, Math.min(1, b.appendRatio))
@@ -2487,12 +2578,15 @@ export class Model {
2487
2578
  scratchQuat[2].setIdentity()
2488
2579
  Quat.slerpInto(scratchQuat[2], scratchQuat[0], absRatio, scratchQuat[1])
2489
2580
 
2490
- // finalRot = slerpResult * finalRot (quat mul)
2581
+ // MMD composes the append on the RIGHT: own animated rotation first, then
2582
+ // the inherited one (saba's `r = r * appendRotate`). We had it on the
2583
+ // left, which is a different rotation whenever a bone carries BOTH its
2584
+ // own key and an append — the twist bones, if a motion keys them.
2491
2585
  const sx = scratchQuat[1].x, sy = scratchQuat[1].y, sz = scratchQuat[1].z, sw = scratchQuat[1].w
2492
- const nx = sw * fx + sx * fw + sy * fz - sz * fy
2493
- const ny = sw * fy - sx * fz + sy * fw + sz * fx
2494
- const nz = sw * fz + sx * fy - sy * fx + sz * fw
2495
- const nw = sw * fw - sx * fx - sy * fy - sz * fz
2586
+ const nx = fw * sx + fx * sw + fy * sz - fz * sy
2587
+ const ny = fw * sy - fx * sz + fy * sw + fz * sx
2588
+ const nz = fw * sz + fx * sy - fy * sx + fz * sw
2589
+ const nw = fw * sw - fx * sx - fy * sy - fz * sz
2496
2590
  fx = nx; fy = ny; fz = nz; fw = nw
2497
2591
  }
2498
2592
 
package/src/pmx-loader.ts CHANGED
@@ -354,6 +354,8 @@ export class PmxLoader {
354
354
  ikIteration?: number
355
355
  ikLimitAngle?: number
356
356
  ikLinks?: IKLink[]
357
+ fixedAxis?: [number, number, number]
358
+ deformLayer?: number
357
359
  }
358
360
  const abs: AbsBone[] = new Array(count)
359
361
  // PMX 2.x bone flags (best-effort common masks)
@@ -372,7 +374,7 @@ export class PmxLoader {
372
374
  const y = this.getFloat32()
373
375
  const z = this.getFloat32()
374
376
  const parentIndex = this.getNonVertexIndex(this.boneIndexSize)
375
- this.getInt32() // transform order (skip)
377
+ const deformLayer = this.getInt32() // 変形階層
376
378
  const flags = this.getUint16()
377
379
 
378
380
  // Tail: bone index or offset vector3
@@ -397,11 +399,10 @@ export class PmxLoader {
397
399
  appendMove = (flags & FLAG_APPEND_MOVE) !== 0
398
400
  }
399
401
 
400
- // Axis limit
402
+ // Axis limit (軸制限)
403
+ let fixedAxis: [number, number, number] | undefined = undefined
401
404
  if ((flags & FLAG_AXIS_LIMIT) !== 0) {
402
- this.getFloat32()
403
- this.getFloat32()
404
- this.getFloat32()
405
+ fixedAxis = [this.getFloat32(), this.getFloat32(), this.getFloat32()]
405
406
  }
406
407
 
407
408
  // Local axis (two vectors x and z)
@@ -467,6 +468,8 @@ export class PmxLoader {
467
468
  appendRatio,
468
469
  appendRotate,
469
470
  appendMove,
471
+ fixedAxis,
472
+ deformLayer,
470
473
  ikTargetIndex,
471
474
  ikIteration,
472
475
  ikLimitAngle,
@@ -487,6 +490,8 @@ export class PmxLoader {
487
490
  appendRatio: a.appendRatio,
488
491
  appendRotate: a.appendRotate,
489
492
  appendMove: a.appendMove,
493
+ fixedAxis: a.fixedAxis,
494
+ deformLayer: a.deformLayer,
490
495
  ikTargetIndex: a.ikTargetIndex,
491
496
  ikIteration: a.ikIteration,
492
497
  ikLimitAngle: a.ikLimitAngle,
@@ -0,0 +1,193 @@
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
+
14
+ import type { DecodedImage } from "./tga-loader"
15
+
16
+ const MAGIC = 0x38425053 // "8BPS", big-endian — PSD is big-endian throughout
17
+
18
+ const enum ColorMode {
19
+ Bitmap = 0,
20
+ Grayscale = 1,
21
+ Indexed = 2,
22
+ RGB = 3,
23
+ CMYK = 4,
24
+ Multichannel = 7,
25
+ Duotone = 8,
26
+ Lab = 9,
27
+ }
28
+
29
+ const MODE_NAMES: Record<number, string> = {
30
+ [ColorMode.Bitmap]: "bitmap",
31
+ [ColorMode.CMYK]: "CMYK",
32
+ [ColorMode.Multichannel]: "multichannel",
33
+ [ColorMode.Lab]: "Lab",
34
+ }
35
+
36
+ /** True when these bytes are a PSD/PSB, by magic rather than by file extension. */
37
+ export function isPsd(buffer: ArrayBuffer): boolean {
38
+ return buffer.byteLength >= 26 && new DataView(buffer).getUint32(0, false) === MAGIC
39
+ }
40
+
41
+ /**
42
+ * PackBits, one row at a time.
43
+ *
44
+ * `end` bounds the row rather than the buffer: the row-length table says how many
45
+ * compressed bytes this row occupies, and trusting the control bytes past that
46
+ * would let one malformed row eat the next one's data.
47
+ */
48
+ function unpackBits(src: Uint8Array, start: number, end: number, dst: Uint8Array, at: number, limit: number): number {
49
+ let i = start
50
+ let o = at
51
+ while (i < end && o < limit) {
52
+ const n = (src[i++] << 24) >> 24 // to signed
53
+ if (n >= 0) {
54
+ const count = Math.min(n + 1, limit - o, end - i)
55
+ for (let k = 0; k < count; k++) dst[o++] = src[i++]
56
+ } else if (n !== -128) {
57
+ // -128 is a no-op by the spec, not a run of 129.
58
+ const count = Math.min(1 - n, limit - o)
59
+ const b = src[i++]
60
+ for (let k = 0; k < count; k++) dst[o++] = b
61
+ }
62
+ }
63
+ return o
64
+ }
65
+
66
+ /**
67
+ * Decode a PSD's composite to RGBA8.
68
+ *
69
+ * RGB, grayscale, indexed and duotone at 8 or 16 bits per channel, raw or RLE —
70
+ * which is every texture that has actually turned up. CMYK and Lab throw: they
71
+ * need a colour conversion that would be guesswork without a profile, and a
72
+ * silently wrong-coloured texture is worse than a missing one.
73
+ */
74
+ export function decodePsd(buffer: ArrayBuffer): DecodedImage {
75
+ const v = new DataView(buffer)
76
+ if (buffer.byteLength < 26 || v.getUint32(0, false) !== MAGIC) throw new Error("not a PSD")
77
+
78
+ const version = v.getUint16(4, false) // 1 = PSD, 2 = PSB
79
+ if (version !== 1 && version !== 2) throw new Error(`PSD unsupported version ${version}`)
80
+ const channels = v.getUint16(12, false)
81
+ const height = v.getUint32(14, false)
82
+ const width = v.getUint32(18, false)
83
+ const depth = v.getUint16(22, false)
84
+ const mode = v.getUint16(24, false)
85
+
86
+ if (width <= 0 || height <= 0) throw new Error(`PSD bad dimensions ${width}x${height}`)
87
+ if (depth !== 8 && depth !== 16) throw new Error(`PSD unsupported bit depth ${depth}`)
88
+ if (mode in MODE_NAMES) throw new Error(`PSD unsupported colour mode: ${MODE_NAMES[mode]}`)
89
+
90
+ // Three variable-length sections stand between the header and the pixels. Only
91
+ // the indexed palette is worth reading; the rest is skipped by its length.
92
+ let p = 26
93
+ const colorDataLen = v.getUint32(p, false)
94
+ const colorData = p + 4
95
+ p = colorData + colorDataLen
96
+ p += 4 + v.getUint32(p, false) // image resources
97
+ // PSB states this length in 8 bytes. Only the low word can matter — the high
98
+ // one would mean a layer section larger than 4GB.
99
+ if (version === 2) {
100
+ p += 8 + v.getUint32(p + 4, false)
101
+ } else {
102
+ p += 4 + v.getUint32(p, false)
103
+ }
104
+ if (p + 2 > buffer.byteLength) throw new Error("PSD truncated before the composite")
105
+
106
+ const compression = v.getUint16(p, false)
107
+ p += 2
108
+
109
+ // Only the channels that carry colour, plus one alpha. A PSD can hold spot and
110
+ // mask channels past those; they are stored after, so ignoring them is a matter
111
+ // of not reading that far.
112
+ const colourChannels = mode === ColorMode.RGB ? 3 : 1
113
+ const hasAlpha = channels > colourChannels
114
+ const used = colourChannels + (hasAlpha ? 1 : 0)
115
+ if (channels < colourChannels) throw new Error(`PSD has ${channels} channel(s), expected ${colourChannels}`)
116
+
117
+ const bytesPerSample = depth === 16 ? 2 : 1
118
+ const planeSamples = width * height
119
+ const planeBytes = planeSamples * bytesPerSample
120
+ const planes = new Uint8Array(used * planeBytes)
121
+
122
+ if (compression === 0) {
123
+ const need = used * planeBytes
124
+ if (p + need > buffer.byteLength) throw new Error("PSD truncated composite")
125
+ planes.set(new Uint8Array(buffer, p, need))
126
+ } else if (compression === 1) {
127
+ // A table of per-row compressed lengths for EVERY channel comes first,
128
+ // including the channels being skipped — so the table is read in full even
129
+ // though only the first `used` channels' rows are decoded.
130
+ const countBytes = version === 2 ? 4 : 2
131
+ const tableBytes = channels * height * countBytes
132
+ if (p + tableBytes > buffer.byteLength) 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) throw new Error("PSD truncated composite")
143
+ if (c < used) {
144
+ const rowStart = c * planeBytes + y * width * bytesPerSample
145
+ unpackBits(src, at, at + len, planes, rowStart, rowStart + width * bytesPerSample)
146
+ }
147
+ at += len
148
+ }
149
+ }
150
+ } else {
151
+ // 2 and 3 are the Zip codes. They appear on layer data, effectively never on
152
+ // the composite, and inflating would mean shipping a decompressor.
153
+ throw new Error(`PSD unsupported compression ${compression}`)
154
+ }
155
+
156
+ // 16-bit samples are big-endian; the high byte is the 8-bit value.
157
+ const sample = (plane: number, i: number): number =>
158
+ depth === 16 ? planes[plane * planeBytes + i * 2] : planes[plane * planeBytes + i]
159
+
160
+ const rgba = new Uint8Array(planeSamples * 4)
161
+
162
+ if (mode === ColorMode.Indexed) {
163
+ // The palette is the colour mode data: 256 reds, then 256 greens, then blues.
164
+ if (colorDataLen < 768) throw new Error("PSD indexed image has no palette")
165
+ const pal = new Uint8Array(buffer, colorData, 768)
166
+ for (let i = 0; i < planeSamples; i++) {
167
+ const idx = sample(0, i)
168
+ rgba[i * 4] = pal[idx]
169
+ rgba[i * 4 + 1] = pal[256 + idx]
170
+ rgba[i * 4 + 2] = pal[512 + idx]
171
+ rgba[i * 4 + 3] = hasAlpha ? sample(1, i) : 255
172
+ }
173
+ return { rgba, width, height }
174
+ }
175
+
176
+ for (let i = 0; i < planeSamples; i++) {
177
+ if (mode === ColorMode.RGB) {
178
+ rgba[i * 4] = sample(0, i)
179
+ rgba[i * 4 + 1] = sample(1, i)
180
+ rgba[i * 4 + 2] = sample(2, i)
181
+ } else {
182
+ // Grayscale and duotone: one plane across all three. Duotone's ink colours
183
+ // live in the colour mode data, and the spec's own advice is to treat the
184
+ // data as grayscale — which is what every other reader does.
185
+ const g = sample(0, i)
186
+ rgba[i * 4] = g
187
+ rgba[i * 4 + 1] = g
188
+ rgba[i * 4 + 2] = g
189
+ }
190
+ rgba[i * 4 + 3] = hasAlpha ? sample(colourChannels, i) : 255
191
+ }
192
+ return { rgba, width, height }
193
+ }