gputex 0.5.0 → 0.7.0

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/dist/index.js CHANGED
@@ -44,6 +44,10 @@ function detectCapabilities(adapter) {
44
44
  }
45
45
 
46
46
  // src/workarounds.ts
47
+ function needsWriteTextureWorkaround(adapter) {
48
+ const { vendor, architecture } = adapter.info ?? {};
49
+ return vendor === "img-tec" && architecture === "d-series";
50
+ }
47
51
  function uploadSourceTexture(device, srcTex, source, width, height, flipY) {
48
52
  if (source instanceof ImageData && !flipY) {
49
53
  if (srcTex.format === "rg8unorm") {
@@ -68,6 +72,22 @@ function uploadSourceTexture(device, srcTex, source, width, height, flipY) {
68
72
 
69
73
  // src/Encoder.ts
70
74
  var CHAIN_ALIGN = 256;
75
+ var PARAMS_SIZE = 32;
76
+ var PARAMS_ALIGN = 256;
77
+ var MAX_BANDS = 8;
78
+ var BAND_BYTES = 2 << 20;
79
+ function bandRows(blocksY, outByteLen, wgY) {
80
+ const n = Math.min(MAX_BANDS, Math.max(1, Math.round(outByteLen / BAND_BYTES)));
81
+ const per = Math.ceil(blocksY / n / wgY) * wgY;
82
+ const bands = [];
83
+ for (let y0 = 0; y0 < blocksY; y0 += per) bands.push({ y0, rows: Math.min(per, blocksY - y0) });
84
+ return bands;
85
+ }
86
+ function allocPrefaulted(byteLength) {
87
+ const out = new Uint8Array(byteLength);
88
+ for (let i = 0; i < byteLength; i += 4096) out[i] = 0;
89
+ return out;
90
+ }
71
91
  var Encoder = class {
72
92
  /**
73
93
  * Subclasses set this to the WebGPU feature string the output texture
@@ -140,10 +160,11 @@ var Encoder = class {
140
160
  _cachedSrcSource = null;
141
161
  _cachedSrcFlipY = false;
142
162
  _cachedDst = null;
143
- _cachedStaging = null;
163
+ // One MAP_READ staging buffer and bind group per row band (see bandRows).
164
+ _cachedStagings = [];
144
165
  _cachedParams = null;
145
- _lastParams = null;
146
- _cachedBindGroup = null;
166
+ _lastParamsKey = null;
167
+ _cachedBindGroups = [];
147
168
  _cachedPrepPlanes = null;
148
169
  _cachedPrepBindGroup = null;
149
170
  _resourcesBusy = false;
@@ -163,8 +184,11 @@ var Encoder = class {
163
184
  _chainPrepBindGroups = [];
164
185
  _chainParams = null;
165
186
  _chainBindGroups = [];
187
+ // Level-0 bind groups of the base-level row bands (see bandRows).
188
+ _chainBandBindGroups = [];
166
189
  _chainDst = null;
167
- _chainStaging = null;
190
+ // One MAP_READ staging buffer per chain submission (base-level band).
191
+ _chainStagings = [];
168
192
  _chainBusy = false;
169
193
  constructor({ device, adapter, ownsDevice = false, disableF16 = false }) {
170
194
  this.device = device;
@@ -208,14 +232,14 @@ var Encoder = class {
208
232
  this._cachedPrepPlanes = null;
209
233
  this._cachedPrepBindGroup = null;
210
234
  this._cachedDst?.destroy();
211
- this._cachedStaging?.destroy();
235
+ for (const st of this._cachedStagings) st.destroy();
212
236
  this._cachedParams?.destroy();
213
237
  this._cachedSrcTex = null;
214
238
  this._cachedSrcSource = null;
215
239
  this._cachedDst = null;
216
- this._cachedStaging = null;
240
+ this._cachedStagings = [];
217
241
  this._cachedParams = null;
218
- this._cachedBindGroup = null;
242
+ this._cachedBindGroups = [];
219
243
  for (const tex of this._chainTextures) tex.destroy();
220
244
  this._chainTextures = [];
221
245
  for (const planes of this._chainPrepPlanes) for (const t of planes) t.destroy();
@@ -223,11 +247,12 @@ var Encoder = class {
223
247
  this._chainPrepBindGroups = [];
224
248
  this._chainParams?.destroy();
225
249
  this._chainDst?.destroy();
226
- this._chainStaging?.destroy();
250
+ for (const st of this._chainStagings) st.destroy();
227
251
  this._chainParams = null;
228
252
  this._chainDst = null;
229
- this._chainStaging = null;
253
+ this._chainStagings = [];
230
254
  this._chainBindGroups = [];
255
+ this._chainBandBindGroups = [];
231
256
  this._chainSig = null;
232
257
  if (this.ownsDevice) this.device.destroy();
233
258
  }
@@ -314,7 +339,7 @@ var Encoder = class {
314
339
  entries: [
315
340
  { binding: 0, resource: srcView },
316
341
  { binding: 1, resource: planes[0].createView() },
317
- { binding: 2, resource: { buffer: params, offset: paramsOffset, size: 16 } },
342
+ { binding: 2, resource: { buffer: params, offset: paramsOffset, size: PARAMS_SIZE } },
318
343
  { binding: 3, resource: planes[1].createView() }
319
344
  ]
320
345
  });
@@ -358,7 +383,8 @@ var Encoder = class {
358
383
  let srcTex;
359
384
  let dstBuffer;
360
385
  let paramsBuffer;
361
- let staging;
386
+ const stagings = [];
387
+ const transientStagings = [];
362
388
  let transientPrepPlanes = null;
363
389
  try {
364
390
  let srcTexIsNew = true;
@@ -421,66 +447,82 @@ var Encoder = class {
421
447
  this._cachedDst = dstBuffer;
422
448
  }
423
449
  }
424
- if (useCache && this._cachedStaging && this._cachedStaging.size >= outByteLen) {
425
- staging = this._cachedStaging;
426
- } else {
427
- staging = device.createBuffer({
428
- label: `${this.label}-staging`,
429
- size: outByteLen,
430
- usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ
431
- });
432
- if (useCache) {
433
- this._cachedStaging?.destroy();
434
- this._cachedStaging = staging;
450
+ const [wgX, wgY] = this.workgroupSize;
451
+ const bands = bandRows(blocksY, outByteLen, wgY);
452
+ const rowBytes = blocksX * this.bytesPerBlock;
453
+ for (let b = 0; b < bands.length; b++) {
454
+ const len = bands[b].rows * rowBytes;
455
+ let st = useCache ? this._cachedStagings[b] : void 0;
456
+ if (!st || st.size < len) {
457
+ st = device.createBuffer({
458
+ label: `${this.label}-staging-${b}`,
459
+ size: len,
460
+ usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ
461
+ });
462
+ if (useCache) {
463
+ this._cachedStagings[b]?.destroy();
464
+ this._cachedStagings[b] = st;
465
+ } else {
466
+ transientStagings.push(st);
467
+ }
435
468
  }
469
+ stagings.push(st);
436
470
  }
471
+ const paramsData = new Uint32Array(MAX_BANDS * PARAMS_ALIGN / 4);
472
+ bands.forEach((band, b) => {
473
+ paramsData.set([blocksX, blocksY, width, height, band.y0], b * PARAMS_ALIGN / 4);
474
+ });
475
+ const paramsKey = `${blocksX},${blocksY},${width},${height},${bands.length}`;
437
476
  if (useCache) {
438
477
  if (!this._cachedParams) {
439
478
  this._cachedParams = device.createBuffer({
440
479
  label: `${this.label}-params`,
441
- size: 16,
480
+ size: MAX_BANDS * PARAMS_ALIGN,
442
481
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
443
482
  });
444
- this._lastParams = null;
483
+ this._lastParamsKey = null;
445
484
  }
446
485
  paramsBuffer = this._cachedParams;
447
- const lp = this._lastParams;
448
- if (!lp || lp[0] !== blocksX || lp[1] !== blocksY || lp[2] !== width || lp[3] !== height) {
449
- device.queue.writeBuffer(paramsBuffer, 0, new Uint32Array([blocksX, blocksY, width, height]));
450
- this._lastParams = [blocksX, blocksY, width, height];
486
+ if (this._lastParamsKey !== paramsKey) {
487
+ device.queue.writeBuffer(paramsBuffer, 0, paramsData);
488
+ this._lastParamsKey = paramsKey;
451
489
  }
452
490
  } else {
453
491
  paramsBuffer = device.createBuffer({
454
492
  label: `${this.label}-params`,
455
- size: 16,
493
+ size: MAX_BANDS * PARAMS_ALIGN,
456
494
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
457
495
  });
458
- device.queue.writeBuffer(paramsBuffer, 0, new Uint32Array([blocksX, blocksY, width, height]));
496
+ device.queue.writeBuffer(paramsBuffer, 0, paramsData);
459
497
  }
460
- if (useCache && (srcTexIsNew || dstIsNew || prepPlanesNew)) this._cachedBindGroup = null;
461
- let bindGroup = useCache ? this._cachedBindGroup : null;
462
- if (!bindGroup) {
463
- const entries = [
464
- { binding: 0, resource: prepPlanes ? prepPlanes[0].createView() : srcTex.createView() },
465
- { binding: 1, resource: { buffer: dstBuffer } },
466
- { binding: 2, resource: { buffer: paramsBuffer } }
467
- ];
468
- if (prepPlanes) {
469
- entries.push({ binding: 3, resource: prepPlanes[1].createView() });
470
- } else if (this._usesSampler) {
471
- this._sampler ??= device.createSampler({
472
- label: `${this.label}-clamp-sampler`,
473
- addressModeU: "clamp-to-edge",
474
- addressModeV: "clamp-to-edge"
498
+ if (useCache && (srcTexIsNew || dstIsNew || prepPlanesNew)) this._cachedBindGroups = [];
499
+ const bindGroups = [];
500
+ for (let b = 0; b < bands.length; b++) {
501
+ let bg = useCache ? this._cachedBindGroups[b] : void 0;
502
+ if (!bg) {
503
+ const entries2 = [
504
+ { binding: 0, resource: prepPlanes ? prepPlanes[0].createView() : srcTex.createView() },
505
+ { binding: 1, resource: { buffer: dstBuffer } },
506
+ { binding: 2, resource: { buffer: paramsBuffer, offset: b * PARAMS_ALIGN, size: PARAMS_SIZE } }
507
+ ];
508
+ if (prepPlanes) {
509
+ entries2.push({ binding: 3, resource: prepPlanes[1].createView() });
510
+ } else if (this._usesSampler) {
511
+ this._sampler ??= device.createSampler({
512
+ label: `${this.label}-clamp-sampler`,
513
+ addressModeU: "clamp-to-edge",
514
+ addressModeV: "clamp-to-edge"
515
+ });
516
+ entries2.push({ binding: 3, resource: this._sampler });
517
+ }
518
+ bg = device.createBindGroup({
519
+ label: `${this.label}-bg-${b}`,
520
+ layout: pipeline.getBindGroupLayout(0),
521
+ entries: entries2
475
522
  });
476
- entries.push({ binding: 3, resource: this._sampler });
523
+ if (useCache) this._cachedBindGroups[b] = bg;
477
524
  }
478
- bindGroup = device.createBindGroup({
479
- label: `${this.label}-bg`,
480
- layout: pipeline.getBindGroupLayout(0),
481
- entries
482
- });
483
- if (useCache) this._cachedBindGroup = bindGroup;
525
+ bindGroups.push(bg);
484
526
  }
485
527
  let prepBindGroup = useCache && !prepPlanesNew && !srcTexIsNew ? this._cachedPrepBindGroup : null;
486
528
  if (prepPipeline && prepPlanes && !prepBindGroup) {
@@ -488,41 +530,48 @@ var Encoder = class {
488
530
  if (useCache) this._cachedPrepBindGroup = prepBindGroup;
489
531
  }
490
532
  const timing = withGpuTime ? this._createTiming() : null;
491
- const [wgX, wgY] = this.workgroupSize;
492
533
  const t0 = performance.now();
493
- const enc = device.createCommandEncoder({ label: `${this.label}-encode` });
494
- if (prepPipeline && prepBindGroup) {
495
- const prepPass = enc.beginComputePass(
496
- timing ? { timestampWrites: { querySet: timing.querySet, beginningOfPassWriteIndex: 0 } } : void 0
497
- );
498
- prepPass.setPipeline(prepPipeline);
499
- prepPass.setBindGroup(0, prepBindGroup);
500
- const [px, py] = this.prepDispatch(blocksX, blocksY);
501
- prepPass.dispatchWorkgroups(Math.ceil(px / wgX), Math.ceil(py / wgY), 1);
502
- prepPass.end();
534
+ const maps = [];
535
+ for (let b = 0; b < bands.length; b++) {
536
+ const { y0, rows } = bands[b];
537
+ const first = b === 0;
538
+ const last = b === bands.length - 1;
539
+ const enc = device.createCommandEncoder({ label: `${this.label}-encode-${b}` });
540
+ if (first && prepPipeline && prepBindGroup) {
541
+ const prepPass = enc.beginComputePass(
542
+ timing ? { timestampWrites: { querySet: timing.querySet, beginningOfPassWriteIndex: 0 } } : void 0
543
+ );
544
+ prepPass.setPipeline(prepPipeline);
545
+ prepPass.setBindGroup(0, prepBindGroup);
546
+ const [px, py] = this.prepDispatch(blocksX, blocksY);
547
+ prepPass.dispatchWorkgroups(Math.ceil(px / wgX), Math.ceil(py / wgY), 1);
548
+ prepPass.end();
549
+ }
550
+ const tsw = timing && (first || last) ? {
551
+ querySet: timing.querySet,
552
+ ...first && !prepPipeline ? { beginningOfPassWriteIndex: 0 } : {},
553
+ ...last ? { endOfPassWriteIndex: 1 } : {}
554
+ } : void 0;
555
+ const pass = enc.beginComputePass(tsw ? { timestampWrites: tsw } : void 0);
556
+ pass.setPipeline(pipeline);
557
+ pass.setBindGroup(0, bindGroups[b]);
558
+ pass.dispatchWorkgroups(Math.ceil(blocksX / wgX), Math.ceil(rows / wgY), 1);
559
+ pass.end();
560
+ enc.copyBufferToBuffer(dstBuffer, y0 * rowBytes, stagings[b], 0, rows * rowBytes);
561
+ if (timing && last) {
562
+ enc.resolveQuerySet(timing.querySet, 0, 2, timing.resolve, 0);
563
+ enc.copyBufferToBuffer(timing.resolve, 0, timing.staging, 0, 16);
564
+ }
565
+ device.queue.submit([enc.finish()]);
566
+ maps.push(stagings[b].mapAsync(GPUMapMode.READ, 0, rows * rowBytes));
503
567
  }
504
- const pass = enc.beginComputePass(
505
- timing ? {
506
- timestampWrites: {
507
- querySet: timing.querySet,
508
- ...prepPipeline ? {} : { beginningOfPassWriteIndex: 0 },
509
- endOfPassWriteIndex: 1
510
- }
511
- } : void 0
512
- );
513
- pass.setPipeline(pipeline);
514
- pass.setBindGroup(0, bindGroup);
515
- pass.dispatchWorkgroups(Math.ceil(blocksX / wgX), Math.ceil(blocksY / wgY), 1);
516
- pass.end();
517
- enc.copyBufferToBuffer(dstBuffer, 0, staging, 0, outByteLen);
518
- if (timing) {
519
- enc.resolveQuerySet(timing.querySet, 0, 2, timing.resolve, 0);
520
- enc.copyBufferToBuffer(timing.resolve, 0, timing.staging, 0, 16);
568
+ const data = allocPrefaulted(outByteLen);
569
+ for (let b = 0; b < bands.length; b++) {
570
+ const { y0, rows } = bands[b];
571
+ await maps[b];
572
+ data.set(new Uint8Array(stagings[b].getMappedRange(0, rows * rowBytes)), y0 * rowBytes);
573
+ stagings[b].unmap();
521
574
  }
522
- device.queue.submit([enc.finish()]);
523
- await staging.mapAsync(GPUMapMode.READ, 0, outByteLen);
524
- const data = new Uint8Array(staging.getMappedRange(0, outByteLen).slice(0));
525
- staging.unmap();
526
575
  const encodeMs = performance.now() - t0;
527
576
  const gpuMs = timing ? await this._readTimingMs(timing) : void 0;
528
577
  return { width, height, paddedWidth, paddedHeight, data, encodeMs, gpuMs };
@@ -532,7 +581,7 @@ var Encoder = class {
532
581
  } else {
533
582
  srcTex?.destroy();
534
583
  dstBuffer?.destroy();
535
- staging?.destroy();
584
+ for (const st of transientStagings) st.destroy();
536
585
  paramsBuffer?.destroy();
537
586
  if (transientPrepPlanes) for (const t of transientPrepPlanes) t.destroy();
538
587
  }
@@ -575,7 +624,7 @@ var Encoder = class {
575
624
  );
576
625
  }
577
626
  });
578
- const { geoms, byteSpan } = this._chainGeometry(levels);
627
+ const { geoms, byteSpan, bands } = this._chainGeometry(levels);
579
628
  const sig = geoms.map((g) => `${g.width}x${g.height}`).join();
580
629
  const useCache = !this._chainBusy;
581
630
  if (useCache) this._chainBusy = true;
@@ -584,8 +633,9 @@ var Encoder = class {
584
633
  let prepBindGroups;
585
634
  let params;
586
635
  let bindGroups;
636
+ let bandBindGroups;
587
637
  let dst;
588
- let staging;
638
+ const transientStagings = [];
589
639
  let transientLevelSet = false;
590
640
  try {
591
641
  let dstIsNew = true;
@@ -607,6 +657,7 @@ var Encoder = class {
607
657
  textures = this._chainTextures;
608
658
  params = this._chainParams;
609
659
  bindGroups = this._chainBindGroups;
660
+ bandBindGroups = this._chainBandBindGroups;
610
661
  prepPlaneSets = this._chainPrepPlanes;
611
662
  prepBindGroups = this._chainPrepBindGroups;
612
663
  } else {
@@ -621,16 +672,7 @@ var Encoder = class {
621
672
  })
622
673
  );
623
674
  textures = texs;
624
- params = device.createBuffer({
625
- label: `${this.label}-chain-params`,
626
- size: geoms.length * CHAIN_ALIGN,
627
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
628
- });
629
- const paramsData = new Uint32Array(geoms.length * CHAIN_ALIGN / 4);
630
- geoms.forEach((g, i) => {
631
- paramsData.set([g.blocksX, g.blocksY, g.width, g.height], i * CHAIN_ALIGN / 4);
632
- });
633
- device.queue.writeBuffer(params, 0, paramsData);
675
+ params = this._createChainParams(geoms, bands);
634
676
  if (this._usesSampler) {
635
677
  this._sampler ??= device.createSampler({
636
678
  label: `${this.label}-clamp-sampler`,
@@ -645,26 +687,29 @@ var Encoder = class {
645
687
  prepBindGroups = prepPipeline ? geoms.map(
646
688
  (g, i) => this._createPrepBindGroup(prepPipeline, texs[i].createView(), planeSets[i], paramsBuf, i * CHAIN_ALIGN)
647
689
  ) : [];
648
- bindGroups = geoms.map((g, i) => {
649
- const entries = [
690
+ const makeBg = (i, slot) => {
691
+ const g = geoms[i];
692
+ const entries2 = [
650
693
  {
651
694
  binding: 0,
652
695
  resource: prepPipeline ? planeSets[i][0].createView() : texs[i].createView()
653
696
  },
654
697
  { binding: 1, resource: { buffer: dstBuf, offset: g.dstOffset, size: g.byteLen } },
655
- { binding: 2, resource: { buffer: paramsBuf, offset: i * CHAIN_ALIGN, size: 16 } }
698
+ { binding: 2, resource: { buffer: paramsBuf, offset: slot * CHAIN_ALIGN, size: PARAMS_SIZE } }
656
699
  ];
657
700
  if (prepPipeline) {
658
- entries.push({ binding: 3, resource: planeSets[i][1].createView() });
701
+ entries2.push({ binding: 3, resource: planeSets[i][1].createView() });
659
702
  } else if (this._usesSampler) {
660
- entries.push({ binding: 3, resource: this._sampler });
703
+ entries2.push({ binding: 3, resource: this._sampler });
661
704
  }
662
705
  return device.createBindGroup({
663
706
  label: `${this.label}-chain-bg-${i}`,
664
707
  layout: pipeline.getBindGroupLayout(0),
665
- entries
708
+ entries: entries2
666
709
  });
667
- });
710
+ };
711
+ bindGroups = geoms.map((_, i) => makeBg(i, i));
712
+ bandBindGroups = bands.map((_, b) => makeBg(0, geoms.length + b));
668
713
  if (useCache) {
669
714
  for (const tex of this._chainTextures) tex.destroy();
670
715
  for (const planes of this._chainPrepPlanes) for (const t of planes) t.destroy();
@@ -674,24 +719,12 @@ var Encoder = class {
674
719
  this._chainPrepBindGroups = prepBindGroups;
675
720
  this._chainParams = params;
676
721
  this._chainBindGroups = bindGroups;
722
+ this._chainBandBindGroups = bandBindGroups;
677
723
  this._chainSig = sig;
678
724
  } else {
679
725
  transientLevelSet = true;
680
726
  }
681
727
  }
682
- if (useCache && this._chainStaging && this._chainStaging.size >= byteSpan) {
683
- staging = this._chainStaging;
684
- } else {
685
- staging = device.createBuffer({
686
- label: `${this.label}-chain-staging`,
687
- size: byteSpan,
688
- usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ
689
- });
690
- if (useCache) {
691
- this._chainStaging?.destroy();
692
- this._chainStaging = staging;
693
- }
694
- }
695
728
  for (let i = 0; i < levels.length; i++) {
696
729
  const level = levels[i];
697
730
  device.queue.writeTexture({ texture: textures[i] }, level.data, { bytesPerRow: level.width * 4 }, [
@@ -704,9 +737,12 @@ var Encoder = class {
704
737
  pipeline,
705
738
  geoms,
706
739
  byteSpan,
740
+ bands,
707
741
  bindGroups,
742
+ bandBindGroups,
708
743
  dst,
709
- staging,
744
+ useCache,
745
+ transientStagings,
710
746
  withGpuTime,
711
747
  t0,
712
748
  prepPipeline && prepBindGroups ? { pipeline: prepPipeline, bindGroups: prepBindGroups } : null
@@ -716,8 +752,8 @@ var Encoder = class {
716
752
  this._chainBusy = false;
717
753
  } else {
718
754
  dst?.destroy();
719
- staging?.destroy();
720
755
  }
756
+ for (const st of transientStagings) st.destroy();
721
757
  if (transientLevelSet) {
722
758
  if (textures) for (const tex of textures) tex.destroy();
723
759
  if (prepPlaneSets) for (const planes of prepPlaneSets) for (const t of planes) t.destroy();
@@ -747,11 +783,11 @@ var Encoder = class {
747
783
  for (let i = 0; i < srcTex.mipLevelCount; i++) {
748
784
  dims.push({ width: Math.max(1, srcTex.width >> i), height: Math.max(1, srcTex.height >> i) });
749
785
  }
750
- const { geoms, byteSpan } = this._chainGeometry(dims);
786
+ const { geoms, byteSpan, bands } = this._chainGeometry(dims);
751
787
  const useCache = !this._chainBusy;
752
788
  if (useCache) this._chainBusy = true;
753
789
  let dst;
754
- let staging;
790
+ const transientStagings = [];
755
791
  let params;
756
792
  let planeSets = null;
757
793
  try {
@@ -769,29 +805,7 @@ var Encoder = class {
769
805
  this._chainSig = null;
770
806
  }
771
807
  }
772
- if (useCache && this._chainStaging && this._chainStaging.size >= byteSpan) {
773
- staging = this._chainStaging;
774
- } else {
775
- staging = device.createBuffer({
776
- label: `${this.label}-chain-staging`,
777
- size: byteSpan,
778
- usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ
779
- });
780
- if (useCache) {
781
- this._chainStaging?.destroy();
782
- this._chainStaging = staging;
783
- }
784
- }
785
- params = device.createBuffer({
786
- label: `${this.label}-chain-params`,
787
- size: geoms.length * CHAIN_ALIGN,
788
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
789
- });
790
- const paramsData = new Uint32Array(geoms.length * CHAIN_ALIGN / 4);
791
- geoms.forEach((g, i) => {
792
- paramsData.set([g.blocksX, g.blocksY, g.width, g.height], i * CHAIN_ALIGN / 4);
793
- });
794
- device.queue.writeBuffer(params, 0, paramsData);
808
+ params = this._createChainParams(geoms, bands);
795
809
  if (this._usesSampler) {
796
810
  this._sampler ??= device.createSampler({
797
811
  label: `${this.label}-clamp-sampler`,
@@ -812,33 +826,39 @@ var Encoder = class {
812
826
  i * CHAIN_ALIGN
813
827
  )
814
828
  ) : null;
815
- const bindGroups = geoms.map((g, i) => {
816
- const entries = [
829
+ const makeBg = (i, slot) => {
830
+ const g = geoms[i];
831
+ const entries2 = [
817
832
  {
818
833
  binding: 0,
819
834
  resource: planes ? planes[i][0].createView() : srcTex.createView({ baseMipLevel: i, mipLevelCount: 1 })
820
835
  },
821
836
  { binding: 1, resource: { buffer: dstBuf, offset: g.dstOffset, size: g.byteLen } },
822
- { binding: 2, resource: { buffer: paramsBuf, offset: i * CHAIN_ALIGN, size: 16 } }
837
+ { binding: 2, resource: { buffer: paramsBuf, offset: slot * CHAIN_ALIGN, size: PARAMS_SIZE } }
823
838
  ];
824
839
  if (planes) {
825
- entries.push({ binding: 3, resource: planes[i][1].createView() });
840
+ entries2.push({ binding: 3, resource: planes[i][1].createView() });
826
841
  } else if (this._usesSampler) {
827
- entries.push({ binding: 3, resource: this._sampler });
842
+ entries2.push({ binding: 3, resource: this._sampler });
828
843
  }
829
844
  return device.createBindGroup({
830
845
  label: `${this.label}-chain-bg-${i}`,
831
846
  layout: pipeline.getBindGroupLayout(0),
832
- entries
847
+ entries: entries2
833
848
  });
834
- });
849
+ };
850
+ const bindGroups = geoms.map((_, i) => makeBg(i, i));
851
+ const bandBindGroups = bands.map((_, b) => makeBg(0, geoms.length + b));
835
852
  return await this._submitChainAndRead(
836
853
  pipeline,
837
854
  geoms,
838
855
  byteSpan,
856
+ bands,
839
857
  bindGroups,
858
+ bandBindGroups,
840
859
  dst,
841
- staging,
860
+ useCache,
861
+ transientStagings,
842
862
  withGpuTime,
843
863
  t0,
844
864
  prepPipeline && prepBindGroups ? { pipeline: prepPipeline, bindGroups: prepBindGroups } : null
@@ -848,8 +868,8 @@ var Encoder = class {
848
868
  this._chainBusy = false;
849
869
  } else {
850
870
  dst?.destroy();
851
- staging?.destroy();
852
871
  }
872
+ for (const st of transientStagings) st.destroy();
853
873
  params?.destroy();
854
874
  if (planeSets) for (const planes of planeSets) for (const t of planes) t.destroy();
855
875
  }
@@ -857,7 +877,7 @@ var Encoder = class {
857
877
  /** Block-grid geometry + packed output offsets for a chain of levels.
858
878
  * `byteSpan` is both the dst buffer size and the readback copy size (a
859
879
  * multiple of 4: byteLen is a multiple of bytesPerBlock ≥ 8, offsets are
860
- * CHAIN_ALIGN-ed). */
880
+ * CHAIN_ALIGN-ed). `bands` splits the base level into row bands. */
861
881
  _chainGeometry(dims) {
862
882
  let dstCursor = 0;
863
883
  const geoms = dims.map(({ width, height }) => {
@@ -871,60 +891,131 @@ var Encoder = class {
871
891
  return { width, height, paddedWidth, paddedHeight, blocksX, blocksY, byteLen, dstOffset };
872
892
  });
873
893
  const last = geoms[geoms.length - 1];
874
- return { geoms, byteSpan: last.dstOffset + last.byteLen };
894
+ const base = geoms[0];
895
+ return {
896
+ geoms,
897
+ byteSpan: last.dstOffset + last.byteLen,
898
+ bands: bandRows(base.blocksY, base.byteLen, this.workgroupSize[1])
899
+ };
875
900
  }
876
- /** Shared chain-encode tail: one compute pass with a dispatch per level,
877
- * one submit, one staging readback sliced into per-level byte arrays. */
878
- async _submitChainAndRead(pipeline, geoms, byteSpan, bindGroups, dst, staging, withGpuTime, t0, prep = null) {
901
+ /** One uniform buffer, one write: level i's { blocksX, blocksY, width,
902
+ * height, y0 = 0 } at slot i, then base-level band b at slot
903
+ * levels + b (y0 = the band's first block row). Slots are CHAIN_ALIGN
904
+ * apart. */
905
+ _createChainParams(geoms, bands) {
906
+ const slots = geoms.length + bands.length;
907
+ const params = this.device.createBuffer({
908
+ label: `${this.label}-chain-params`,
909
+ size: slots * CHAIN_ALIGN,
910
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
911
+ });
912
+ const data = new Uint32Array(slots * CHAIN_ALIGN / 4);
913
+ geoms.forEach((g, i) => data.set([g.blocksX, g.blocksY, g.width, g.height, 0], i * CHAIN_ALIGN / 4));
914
+ const g0 = geoms[0];
915
+ bands.forEach(
916
+ (band, b) => data.set([g0.blocksX, g0.blocksY, g0.width, g0.height, band.y0], (geoms.length + b) * CHAIN_ALIGN / 4)
917
+ );
918
+ this.device.queue.writeBuffer(params, 0, data);
919
+ return params;
920
+ }
921
+ /** Shared chain-encode tail: one submission per base-level row band —
922
+ * band b dispatches its rows of level 0, the last band also every tail
923
+ * level — each read back through its own staging buffer as it lands,
924
+ * then sliced into per-level byte arrays. */
925
+ async _submitChainAndRead(pipeline, geoms, byteSpan, bands, bindGroups, bandBindGroups, dst, useCache, transientStagings, withGpuTime, t0, prep = null) {
879
926
  const device = this.device;
880
927
  const timing = withGpuTime ? this._createTiming() : null;
881
928
  const [wgX, wgY] = this.workgroupSize;
882
- const enc = device.createCommandEncoder({ label: `${this.label}-encode-chain` });
883
- if (prep) {
884
- const prepPass = enc.beginComputePass(
885
- timing ? { timestampWrites: { querySet: timing.querySet, beginningOfPassWriteIndex: 0 } } : void 0
886
- );
887
- prepPass.setPipeline(prep.pipeline);
888
- for (let i = 0; i < geoms.length; i++) {
889
- const g = geoms[i];
890
- prepPass.setBindGroup(0, prep.bindGroups[i]);
891
- const [px, py] = this.prepDispatch(g.blocksX, g.blocksY);
892
- prepPass.dispatchWorkgroups(Math.ceil(px / wgX), Math.ceil(py / wgY), 1);
929
+ const base = geoms[0];
930
+ const rowBytes = base.blocksX * this.bytesPerBlock;
931
+ const ranges = bands.map((band, b) => {
932
+ const start = base.dstOffset + band.y0 * rowBytes;
933
+ const end = b === bands.length - 1 ? byteSpan : start + band.rows * rowBytes;
934
+ return { start, len: end - start };
935
+ });
936
+ const stagings = ranges.map(({ len }, b) => {
937
+ let st = useCache ? this._chainStagings[b] : void 0;
938
+ if (!st || st.size < len) {
939
+ st = device.createBuffer({
940
+ label: `${this.label}-chain-staging-${b}`,
941
+ size: len,
942
+ usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ
943
+ });
944
+ if (useCache) {
945
+ this._chainStagings[b]?.destroy();
946
+ this._chainStagings[b] = st;
947
+ } else {
948
+ transientStagings.push(st);
949
+ }
893
950
  }
894
- prepPass.end();
895
- }
896
- const pass = enc.beginComputePass(
897
- timing ? {
898
- timestampWrites: {
899
- querySet: timing.querySet,
900
- ...prep ? {} : { beginningOfPassWriteIndex: 0 },
901
- endOfPassWriteIndex: 1
951
+ return st;
952
+ });
953
+ const maps = [];
954
+ for (let b = 0; b < bands.length; b++) {
955
+ const first = b === 0;
956
+ const last = b === bands.length - 1;
957
+ const enc = device.createCommandEncoder({ label: `${this.label}-encode-chain-${b}` });
958
+ if (first && prep) {
959
+ const prepPass = enc.beginComputePass(
960
+ timing ? { timestampWrites: { querySet: timing.querySet, beginningOfPassWriteIndex: 0 } } : void 0
961
+ );
962
+ prepPass.setPipeline(prep.pipeline);
963
+ for (let i = 0; i < geoms.length; i++) {
964
+ const g = geoms[i];
965
+ prepPass.setBindGroup(0, prep.bindGroups[i]);
966
+ const [px, py] = this.prepDispatch(g.blocksX, g.blocksY);
967
+ prepPass.dispatchWorkgroups(Math.ceil(px / wgX), Math.ceil(py / wgY), 1);
902
968
  }
903
- } : void 0
904
- );
905
- pass.setPipeline(pipeline);
906
- for (let i = 0; i < geoms.length; i++) {
907
- const g = geoms[i];
908
- pass.setBindGroup(0, bindGroups[i]);
909
- pass.dispatchWorkgroups(Math.ceil(g.blocksX / wgX), Math.ceil(g.blocksY / wgY), 1);
910
- }
911
- pass.end();
912
- enc.copyBufferToBuffer(dst, 0, staging, 0, byteSpan);
913
- if (timing) {
914
- enc.resolveQuerySet(timing.querySet, 0, 2, timing.resolve, 0);
915
- enc.copyBufferToBuffer(timing.resolve, 0, timing.staging, 0, 16);
969
+ prepPass.end();
970
+ }
971
+ const tsw = timing && (first || last) ? {
972
+ querySet: timing.querySet,
973
+ ...first && !prep ? { beginningOfPassWriteIndex: 0 } : {},
974
+ ...last ? { endOfPassWriteIndex: 1 } : {}
975
+ } : void 0;
976
+ const pass = enc.beginComputePass(tsw ? { timestampWrites: tsw } : void 0);
977
+ pass.setPipeline(pipeline);
978
+ pass.setBindGroup(0, bandBindGroups[b]);
979
+ pass.dispatchWorkgroups(Math.ceil(base.blocksX / wgX), Math.ceil(bands[b].rows / wgY), 1);
980
+ if (last) {
981
+ for (let i = 1; i < geoms.length; i++) {
982
+ const g = geoms[i];
983
+ pass.setBindGroup(0, bindGroups[i]);
984
+ pass.dispatchWorkgroups(Math.ceil(g.blocksX / wgX), Math.ceil(g.blocksY / wgY), 1);
985
+ }
986
+ }
987
+ pass.end();
988
+ enc.copyBufferToBuffer(dst, ranges[b].start, stagings[b], 0, ranges[b].len);
989
+ if (timing && last) {
990
+ enc.resolveQuerySet(timing.querySet, 0, 2, timing.resolve, 0);
991
+ enc.copyBufferToBuffer(timing.resolve, 0, timing.staging, 0, 16);
992
+ }
993
+ device.queue.submit([enc.finish()]);
994
+ maps.push(stagings[b].mapAsync(GPUMapMode.READ, 0, ranges[b].len));
916
995
  }
917
- device.queue.submit([enc.finish()]);
918
- await staging.mapAsync(GPUMapMode.READ, 0, byteSpan);
919
- const mapped = staging.getMappedRange(0, byteSpan);
920
996
  const out = geoms.map((g) => ({
921
997
  width: g.width,
922
998
  height: g.height,
923
999
  paddedWidth: g.paddedWidth,
924
1000
  paddedHeight: g.paddedHeight,
925
- data: new Uint8Array(mapped.slice(g.dstOffset, g.dstOffset + g.byteLen))
1001
+ data: allocPrefaulted(g.byteLen)
926
1002
  }));
927
- staging.unmap();
1003
+ for (let b = 0; b < bands.length; b++) {
1004
+ const { start, len } = ranges[b];
1005
+ await maps[b];
1006
+ const mapped = stagings[b].getMappedRange(0, len);
1007
+ if (b < bands.length - 1) {
1008
+ out[0].data.set(new Uint8Array(mapped), start - base.dstOffset);
1009
+ } else {
1010
+ const lvl0Rest = base.dstOffset + base.byteLen - start;
1011
+ out[0].data.set(new Uint8Array(mapped, 0, lvl0Rest), start - base.dstOffset);
1012
+ for (let i = 1; i < geoms.length; i++) {
1013
+ const g = geoms[i];
1014
+ out[i].data.set(new Uint8Array(mapped, g.dstOffset - start, g.byteLen));
1015
+ }
1016
+ }
1017
+ stagings[b].unmap();
1018
+ }
928
1019
  const encodeMs = performance.now() - t0;
929
1020
  const gpuMs = timing ? await this._readTimingMs(timing) : void 0;
930
1021
  return { levels: out, encodeMs, gpuMs };
@@ -968,250 +1059,10 @@ var Encoder = class {
968
1059
  };
969
1060
 
970
1061
  // src/bc1.wgsl
971
- var bc1_default = "// BC1 (DXT1) compute shader encoder.\n//\n// Each invocation encodes one 4x4 pixel block into an 8-byte BC1 block\n// written as 2 x u32 into the destination storage buffer. This is the f32\n// fallback; bc1_fast_f16.wgsl is the same algorithm and is preferred when\n// the device reports shader-f16.\n//\n// BC1 block layout (little-endian):\n// u32[0]: color0 (low 16) | color1 (high 16) both in RGB565\n// u32[1]: 16 x 2-bit indices, pixel 0 = bits 0..1, pixel 15 = bits 30..31\n//\n// We always force the 4-color mode (color0 > color1, numeric 16-bit):\n// idx 0 -> color0\n// idx 1 -> color1\n// idx 2 -> (2*color0 + color1) / 3\n// idx 3 -> ( color0 + 2*color1) / 3\n//\n// ALGORITHM: principal-axis endpoint seed (covariance power-iteration; inset\n// bbox on degenerate blocks), inset by ~half a 565 cell along the axis, then\n// a fused pass that projects every pixel onto the decoded-endpoint line (the\n// 4 palette entries are colinear and evenly spaced, so the nearest entry is\n// the rounded projection \u2014 no 4-entry search) while accumulating the\n// least-squares refit sums, followed by up to TWO refit rounds (re-quantise,\n// reproject with indices packed on the fly, accept only on lower block\n// error).\n\nstruct Params {\n blocks_x: u32,\n blocks_y: u32,\n width: u32,\n height: u32,\n};\n\n@group(0) @binding(0) var src_tex: texture_2d<f32>;\n@group(0) @binding(1) var<storage, read_write> dst: array<u32>;\n@group(0) @binding(2) var<uniform> params: Params;\n\nfn to565(c: vec3<f32>) -> u32 {\n // Round-to-nearest quantization into 5-6-5.\n let r = u32(clamp(floor(c.r * 31.0 + 0.5), 0.0, 31.0));\n let g = u32(clamp(floor(c.g * 63.0 + 0.5), 0.0, 63.0));\n let b = u32(clamp(floor(c.b * 31.0 + 0.5), 0.0, 31.0));\n return (r << 11u) | (g << 5u) | b;\n}\n\nfn from565(c: u32) -> vec3<f32> {\n let r = (c >> 11u) & 31u;\n let g = (c >> 5u) & 63u;\n let b = c & 31u;\n // 5/6-bit -> 8-bit: (x*527+23)>>6 (6-bit: 259/33) \u2014 round-to-nearest\n // scaling, matching bc1_ref.ts and typical hardware decoders (white ->\n // 255). Integer u32 math is exact. NOTE: this is NOT plain bit-replication\n // ((x<<3)|(x>>2)) \u2014 they differ for some codes (e.g. 5-bit 3 -> 25 vs 24).\n // Selecting indices against this palette is what makes the encoder agree\n // with what the GPU will actually sample.\n let r8 = (r * 527u + 23u) >> 6u;\n let g8 = (g * 259u + 33u) >> 6u;\n let b8 = (b * 527u + 23u) >> 6u;\n return vec3<f32>(vec3<u32>(r8, g8, b8)) / 255.0;\n}\n\n// One projection pass against the decoded endpoints of (c0,c1): the packed\n// 2-bit indices, the block's squared error, and the LSQ normal-equation sums\n// of the resulting assignment \u2014 so an accepted refit can seed the next\n// round. Levels s run 0..3 along p0\u2192p1 (palette = p0, p0+\u2153d, p0+\u2154d, p1 \u2014\n// colinear, evenly spaced, so rounding the projection IS the nearest-entry\n// search). Level \u2192 BC1 index: 0\u21920 (c0), 1\u21922 (\u2154c0+\u2153c1), 2\u21923, 3\u21921 (c1); as a\n// packed LUT: (0x78 >> 2L) & 3.\nstruct ProjStats {\n indices: u32,\n err: f32,\n sAA: f32, sBB: f32, sAB: f32,\n sAV: vec3<f32>, sBV: vec3<f32>,\n s_min: f32, s_max: f32,\n};\nfn project_stats(pix: ptr<function, array<vec3<f32>, 16>>, c0: u32, c1: u32) -> ProjStats {\n var out: ProjStats;\n out.indices = 0u;\n out.err = 0.0;\n out.sAA = 0.0; out.sBB = 0.0; out.sAB = 0.0;\n out.sAV = vec3<f32>(0.0); out.sBV = vec3<f32>(0.0);\n out.s_min = 3.0; out.s_max = 0.0;\n let p0 = from565(c0);\n let p1 = from565(c1);\n let dir = p1 - p0;\n let dd = dot(dir, dir);\n if (dd == 0.0) {\n // Unreachable for distinct 565 codes (the decode is injective); kept so\n // a degenerate call still returns a consistent error.\n out.s_min = 0.0;\n for (var k: u32 = 0u; k < 16u; k = k + 1u) {\n let e = (*pix)[k] - p0;\n out.err = out.err + dot(e, e);\n }\n return out;\n }\n let inv = 3.0 / dd;\n for (var k: u32 = 0u; k < 16u; k = k + 1u) {\n let v = (*pix)[k];\n let s = clamp(floor(dot(v - p0, dir) * inv + 0.5), 0.0, 3.0);\n out.s_min = min(out.s_min, s); out.s_max = max(out.s_max, s);\n let b = s * (1.0 / 3.0); let a = 1.0 - b;\n out.sAA = out.sAA + a * a; out.sBB = out.sBB + b * b; out.sAB = out.sAB + a * b;\n out.sAV = out.sAV + a * v; out.sBV = out.sBV + b * v;\n let e = v - (p0 + b * dir);\n out.err = out.err + dot(e, e);\n out.indices = out.indices | (((0x78u >> (u32(s) * 2u)) & 3u) << (k * 2u));\n }\n return out;\n}\n\n// Principal colour axis via covariance power-iteration, seeded with the bbox\n// diagonal. Returns a unit axis, or vec3(0) for a degenerate (constant)\n// block. The bbox diagonal alone is sign-blind and points across\n// anti-correlated data (normal maps, hue edges) instead of along it.\nfn principal_axis(\n pixels: ptr<function, array<vec3<f32>, 16>>,\n mean: vec3<f32>,\n seed: vec3<f32>,\n) -> vec3<f32> {\n // Symmetric 3x3 covariance, stored as its three rows.\n var c0v = vec3<f32>(0.0);\n var c1v = vec3<f32>(0.0);\n var c2v = vec3<f32>(0.0);\n for (var k: u32 = 0u; k < 16u; k = k + 1u) {\n let d = (*pixels)[k] - mean;\n c0v = c0v + d.x * d;\n c1v = c1v + d.y * d;\n c2v = c2v + d.z * d;\n }\n var v = seed;\n var len = length(v);\n if (len < 1e-9) { return vec3<f32>(0.0); }\n v = v / len;\n for (var iter: u32 = 0u; iter < 8u; iter = iter + 1u) {\n let nv = vec3<f32>(dot(c0v, v), dot(c1v, v), dot(c2v, v));\n len = length(nv);\n if (len < 1e-12) { return vec3<f32>(0.0); }\n v = nv / len;\n }\n return v;\n}\n\n@compute @workgroup_size(8, 8, 1)\nfn encode(@builtin(global_invocation_id) gid: vec3<u32>) {\n if (gid.x >= params.blocks_x || gid.y >= params.blocks_y) {\n return;\n }\n\n let bx = gid.x;\n let by = gid.y;\n let block_index = by * params.blocks_x + bx;\n\n let base = vec2<i32>(i32(bx) * 4, i32(by) * 4);\n let max_xy = vec2<i32>(i32(params.width) - 1, i32(params.height) - 1);\n\n var pixels: array<vec3<f32>, 16>;\n var bb_min = vec3<f32>(1.0, 1.0, 1.0);\n var bb_max = vec3<f32>(0.0, 0.0, 0.0);\n var mean = vec3<f32>(0.0);\n var gd = 0.0;\n\n for (var i: u32 = 0u; i < 16u; i = i + 1u) {\n let lx = i32(i & 3u);\n let ly = i32(i >> 2u);\n // Clamp to edge for non-multiple-of-4 textures.\n let p = clamp(base + vec2<i32>(lx, ly), vec2<i32>(0, 0), max_xy);\n let c = textureLoad(src_tex, p, 0).rgb;\n pixels[i] = c;\n bb_min = min(bb_min, c);\n bb_max = max(bb_max, c);\n mean = mean + c;\n gd = max(gd, max(abs(c.x - c.y), abs(c.x - c.z)));\n }\n mean = mean * (1.0 / 16.0);\n // Exactly-gray blocks free the refit from the bbox clamp (no hue to\n // protect; smooth gradients want endpoints outside the data range) \u2014\n // see bc1_fast_f16.wgsl.\n let gray = gd == 0.0;\n let lim_lo = select(bb_min, vec3<f32>(0.0), gray);\n let lim_hi = select(bb_max, vec3<f32>(1.0), gray);\n\n // Seed endpoints from the block's principal colour axis at the exact\n // projection extents, inset by ~half a 565 cell along the axis (stb_dxt\n // heuristic). Degenerate (near-flat) blocks keep the inset-bbox seed.\n var seed_hi: vec3<f32>;\n var seed_lo: vec3<f32>;\n let axis = principal_axis(&pixels, mean, bb_max - bb_min);\n if (dot(axis, axis) > 0.0) {\n var t_min: f32 = 1e30;\n var t_max: f32 = -1e30;\n for (var k: u32 = 0u; k < 16u; k = k + 1u) {\n let t = dot(pixels[k] - mean, axis);\n t_min = min(t_min, t);\n t_max = max(t_max, t);\n }\n let pad = (t_max - t_min) / 16.0;\n seed_hi = clamp(mean + (t_max - pad) * axis, vec3<f32>(0.0), vec3<f32>(1.0));\n seed_lo = clamp(mean + (t_min + pad) * axis, vec3<f32>(0.0), vec3<f32>(1.0));\n } else {\n let inset = (bb_max - bb_min) / 16.0;\n seed_hi = clamp(bb_max - inset, vec3<f32>(0.0), vec3<f32>(1.0));\n seed_lo = clamp(bb_min + inset, vec3<f32>(0.0), vec3<f32>(1.0));\n }\n var c0 = to565(seed_hi);\n var c1 = to565(seed_lo);\n if (c0 == c1) {\n if (c1 > 0u) { c1 = c1 - 1u; } else { c0 = c0 + 1u; }\n } else if (c0 < c1) {\n let t = c0; c0 = c1; c1 = t;\n }\n\n // Fused seed pass, then up to TWO least-squares refit rounds, each\n // accepted only if the block's squared error actually decreases \u2014 the\n // refit minimises a continuous objective and can lose after 565\n // quantisation. Every pass re-accumulates the normal-equation sums, so an\n // accepted round seeds the next.\n var cur = project_stats(&pixels, c0, c1);\n for (var it: u32 = 0u; it < 2u; it = it + 1u) {\n // Refit only on a well-conditioned system: when every pixel lands on\n // ONE level (flat blocks \u2014 the 4-colour nudge forces c0 \u2260 c1 even\n // then) the system is rank-1 and det/numerators are pure float noise;\n // the solve would return garbage endpoints. With \u22652 levels\n // det = \u03A3_i<j (b_j \u2212 b_i)\xB2 \u2265 ~1.67, so 1e-3 is a safe guard.\n if (cur.s_min >= cur.s_max) { break; }\n let det = cur.sAA * cur.sBB - cur.sAB * cur.sAB;\n if (abs(det) <= 1e-3) { break; }\n // Clamp the refit to the block bbox (not [0,1]): on multi-cluster\n // blocks the unconstrained solve extrapolates far outside the block's\n // colours and the per-channel clamp then bends the hue \u2014 fringe pixels\n // decode to colours that exist nowhere in the block. Constraining to\n // the bbox also measures better in plain SSE (+1.6 dB on the colour\n // test card), so the accept-if-better guard below keeps more refits.\n let e0 = clamp((cur.sBB * cur.sAV - cur.sAB * cur.sBV) / det, lim_lo, lim_hi);\n let e1 = clamp((cur.sAA * cur.sBV - cur.sAB * cur.sAV) / det, lim_lo, lim_hi);\n var nc0 = to565(e0);\n var nc1 = to565(e1);\n if (nc0 == nc1) {\n if (nc1 > 0u) { nc1 = nc1 - 1u; } else { nc0 = nc0 + 1u; }\n } else if (nc0 < nc1) {\n let t = nc0; nc0 = nc1; nc1 = t;\n }\n if (nc0 == c0 && nc1 == c1) { break; }\n let nxt = project_stats(&pixels, nc0, nc1);\n if (nxt.err >= cur.err) { break; }\n c0 = nc0;\n c1 = nc1;\n cur = nxt;\n }\n\n let out = block_index * 2u;\n dst[out] = c0 | (c1 << 16u);\n dst[out + 1u] = cur.indices;\n}\n";
1062
+ var bc1_default = "// BC1 (DXT1) compute shader encoder.\n//\n// Each invocation encodes one 4x4 pixel block into an 8-byte BC1 block\n// written as 2 x u32 into the destination storage buffer. This is the f32\n// fallback; bc1_fast_f16.wgsl is the same algorithm and is preferred when\n// the device reports shader-f16.\n//\n// BC1 block layout (little-endian):\n// u32[0]: color0 (low 16) | color1 (high 16) both in RGB565\n// u32[1]: 16 x 2-bit indices, pixel 0 = bits 0..1, pixel 15 = bits 30..31\n//\n// We always force the 4-color mode (color0 > color1, numeric 16-bit):\n// idx 0 -> color0\n// idx 1 -> color1\n// idx 2 -> (2*color0 + color1) / 3\n// idx 3 -> ( color0 + 2*color1) / 3\n//\n// ALGORITHM (same as bc1_fast_f16.wgsl, which documents the measurements):\n// near-flat blocks take a solid colour \u2014 per channel the endpoint pair whose\n// \u2154/\u2153 interpolant lands nearest the block mean; other blocks get a\n// principal-axis endpoint seed (covariance power-iteration; inset bbox on\n// degenerate blocks), inset by ~half a 565 cell along the axis. A\n// projection pass then assigns every pixel the rounded projection onto the\n// decoded-endpoint line (the 4 palette entries are colinear and evenly\n// spaced, so that is the nearest entry) while accumulating the block error\n// and projection moments, followed by up to TWO least-squares refit rounds\n// solved from those moments (re-quantise, reproject, accept only on lower\n// block error).\n\nstruct Params {\n blocks_x: u32,\n blocks_y: u32,\n width: u32,\n height: u32,\n y0: u32, // first block row of this dispatch (row-band encodes)\n};\n\n@group(0) @binding(0) var src_tex: texture_2d<f32>;\n@group(0) @binding(1) var<storage, read_write> dst: array<u32>;\n@group(0) @binding(2) var<uniform> params: Params;\n\nfn to565(c: vec3<f32>) -> u32 {\n // Round-to-nearest quantization into 5-6-5.\n let r = u32(clamp(floor(c.r * 31.0 + 0.5), 0.0, 31.0));\n let g = u32(clamp(floor(c.g * 63.0 + 0.5), 0.0, 63.0));\n let b = u32(clamp(floor(c.b * 31.0 + 0.5), 0.0, 31.0));\n return (r << 11u) | (g << 5u) | b;\n}\n\nfn from565(c: u32) -> vec3<f32> {\n let r = (c >> 11u) & 31u;\n let g = (c >> 5u) & 63u;\n let b = c & 31u;\n // 5/6-bit -> 8-bit: (x*527+23)>>6 (6-bit: 259/33) \u2014 round-to-nearest\n // scaling, matching bc1_ref.ts and typical hardware decoders (white ->\n // 255). Integer u32 math is exact. NOTE: this is NOT plain bit-replication\n // ((x<<3)|(x>>2)) \u2014 they differ for some codes (e.g. 5-bit 3 -> 25 vs 24).\n // Selecting indices against this palette is what makes the encoder agree\n // with what the GPU will actually sample.\n let r8 = (r * 527u + 23u) >> 6u;\n let g8 = (g * 259u + 33u) >> 6u;\n let b8 = (b * 527u + 23u) >> 6u;\n return vec3<f32>(vec3<u32>(r8, g8, b8)) / 255.0;\n}\n\n// Force 4-colour mode: c0 > c1 strictly.\nfn order565(a: u32, b: u32) -> vec2<u32> {\n var c0 = a; var c1 = b;\n if (c0 == c1) {\n if (c1 > 0u) { c1 = c1 - 1u; } else { c0 = c0 + 1u; }\n } else if (c0 < c1) {\n let t = c0; c0 = c1; c1 = t;\n }\n return vec2<u32>(c0, c1);\n}\n\n// One projection pass against the decoded endpoints of (c0,c1): levels\n// L = 0..3 along p0\u2192p1 (palette = p0, p0+\u2153d, p0+\u2154d, p1 \u2014 colinear, evenly\n// spaced, so rounding the projection IS the nearest-entry search), the\n// packed indices, the block's squared error, and the projection MOMENTS a\n// refit needs: \u03A3L, \u03A3L\xB2, \u03A3u, \u03A3L\xB7u (u = v \u2212 p0). Level \u2192 BC1 index: 0\u21920\n// (c0), 1\u21922 (\u2154c0+\u2153c1), 2\u21923, 3\u21921 (c1); as a packed LUT: (0x78 >> 2L) & 3.\nstruct Moments { sL: f32, sLL: f32, sU: vec3<f32>, sLu: vec3<f32>, indices: u32, err: f32 };\nfn moments(pix: ptr<function, array<vec3<f32>, 16>>, c0: u32, c1: u32) -> Moments {\n let p0 = from565(c0);\n let dir = from565(c1) - p0;\n let inv = 3.0 / dot(dir, dir);\n var out: Moments;\n out.sL = 0.0;\n out.sLL = 0.0;\n out.sU = vec3<f32>(0.0);\n out.sLu = vec3<f32>(0.0);\n out.indices = 0u;\n out.err = 0.0;\n for (var k: u32 = 0u; k < 16u; k = k + 1u) {\n let u = (*pix)[k] - p0;\n let L = clamp(floor(dot(u, dir) * inv + 0.5), 0.0, 3.0);\n out.sL = out.sL + L;\n out.sLL = out.sLL + L * L;\n out.sU = out.sU + u;\n out.sLu = out.sLu + L * u;\n out.indices = out.indices | (((0x78u >> (u32(L) * 2u)) & 3u) << (k * 2u));\n let e = u - L * (1.0 / 3.0) * dir;\n out.err = out.err + dot(e, e);\n }\n return out;\n}\n\n// One least-squares refit from moments (b = L/3, a = 1 \u2212 b):\n// sBB = \u03A3L\xB2/9 sAB = \u03A3L/3 \u2212 \u03A3L\xB2/9 sAA = 16 \u2212 2\u03A3L/3 + \u03A3L\xB2/9\n// \u03A3b\xB7u = \u03A3L\xB7u/3 \u03A3a\xB7u = \u03A3u \u2212 \u03A3b\xB7u\n// clamped to [lim_lo, lim_hi], re-quantised and ordered. Returns (c0, c1)\n// unchanged when every pixel sits on ONE level (16\xB7\u03A3L\xB2 == (\u03A3L)\xB2, singular).\nfn solve(m: Moments, c0: u32, c1: u32, lim_lo: vec3<f32>, lim_hi: vec3<f32>) -> vec2<u32> {\n if (16.0 * m.sLL == m.sL * m.sL) { return vec2<u32>(c0, c1); }\n let sBB = m.sLL * (1.0 / 9.0);\n let sAB = m.sL * (1.0 / 3.0) - sBB;\n let sAA = 16.0 - m.sL * (2.0 / 3.0) + sBB;\n let det = sAA * sBB - sAB * sAB;\n let p0 = from565(c0);\n let sBu = m.sLu * (1.0 / 3.0);\n let sAu = m.sU - sBu;\n let e0 = clamp(p0 + (sBB * sAu - sAB * sBu) / det, lim_lo, lim_hi);\n let e1 = clamp(p0 + (sAA * sBu - sAB * sAu) / det, lim_lo, lim_hi);\n return order565(to565(e0), to565(e1));\n}\n\n// Solid-colour channel code: the pair (a, b) of `bits`-bit codes whose \u2154/\u2153\n// interpolant (2\xB7dec(a) + dec(b))/3 \u2014 palette index 2 \u2014 lands nearest v\n// (8-bit units). See bc1_fast_f16.wgsl.\nfn solid_pair(v: f32, bits: u32) -> vec2<u32> {\n let maxc = (1u << bits) - 1u;\n let q = min(u32(v * f32(maxc) / 255.0), maxc - 1u);\n var x: f32; var y: f32;\n if (bits == 5u) {\n x = f32((q * 527u + 23u) >> 6u);\n y = f32(((q + 1u) * 527u + 23u) >> 6u);\n } else {\n x = f32((q * 259u + 33u) >> 6u);\n y = f32(((q + 1u) * 259u + 33u) >> 6u);\n }\n var best = vec2<u32>(q, q);\n var be = abs(x - v);\n let c1 = (2.0 * x + y) / 3.0;\n if (abs(c1 - v) < be) { be = abs(c1 - v); best = vec2<u32>(q, q + 1u); }\n let c2 = (x + 2.0 * y) / 3.0;\n if (abs(c2 - v) < be) { be = abs(c2 - v); best = vec2<u32>(q + 1u, q); }\n if (abs(y - v) < be) { best = vec2<u32>(q + 1u, q + 1u); }\n return best;\n}\n\n// Principal colour axis via covariance power-iteration, seeded with the bbox\n// diagonal. Returns a unit axis, or vec3(0) for a degenerate (constant)\n// block. The bbox diagonal alone is sign-blind and points across\n// anti-correlated data (normal maps, hue edges) instead of along it.\nfn principal_axis(\n pixels: ptr<function, array<vec3<f32>, 16>>,\n mean: vec3<f32>,\n seed: vec3<f32>,\n) -> vec3<f32> {\n // Symmetric 3x3 covariance, stored as its three rows.\n var c0v = vec3<f32>(0.0);\n var c1v = vec3<f32>(0.0);\n var c2v = vec3<f32>(0.0);\n for (var k: u32 = 0u; k < 16u; k = k + 1u) {\n let d = (*pixels)[k] - mean;\n c0v = c0v + d.x * d;\n c1v = c1v + d.y * d;\n c2v = c2v + d.z * d;\n }\n var v = seed;\n var len = length(v);\n if (len < 1e-9) { return vec3<f32>(0.0); }\n v = v / len;\n for (var iter: u32 = 0u; iter < 8u; iter = iter + 1u) {\n let nv = vec3<f32>(dot(c0v, v), dot(c1v, v), dot(c2v, v));\n len = length(nv);\n if (len < 1e-12) { return vec3<f32>(0.0); }\n v = nv / len;\n }\n return v;\n}\n\n@compute @workgroup_size(8, 8, 1)\nfn encode(@builtin(global_invocation_id) gid_raw: vec3<u32>) {\n // Row-band encodes dispatch a slice of the block grid starting at row y0.\n let gid = vec3<u32>(gid_raw.x, gid_raw.y + params.y0, gid_raw.z);\n if (gid.x >= params.blocks_x || gid.y >= params.blocks_y) {\n return;\n }\n\n let bx = gid.x;\n let by = gid.y;\n let block_index = by * params.blocks_x + bx;\n\n let base = vec2<i32>(i32(bx) * 4, i32(by) * 4);\n let max_xy = vec2<i32>(i32(params.width) - 1, i32(params.height) - 1);\n\n var pixels: array<vec3<f32>, 16>;\n var bb_min = vec3<f32>(1.0, 1.0, 1.0);\n var bb_max = vec3<f32>(0.0, 0.0, 0.0);\n var mean = vec3<f32>(0.0);\n var gd = 0.0;\n\n for (var i: u32 = 0u; i < 16u; i = i + 1u) {\n let lx = i32(i & 3u);\n let ly = i32(i >> 2u);\n // Clamp to edge for non-multiple-of-4 textures.\n let p = clamp(base + vec2<i32>(lx, ly), vec2<i32>(0, 0), max_xy);\n let c = textureLoad(src_tex, p, 0).rgb;\n pixels[i] = c;\n bb_min = min(bb_min, c);\n bb_max = max(bb_max, c);\n mean = mean + c;\n gd = max(gd, max(abs(c.x - c.y), abs(c.x - c.z)));\n }\n mean = mean * (1.0 / 16.0);\n // Exactly-gray blocks free the refit from the bbox clamp (no hue to\n // protect; smooth gradients want endpoints outside the data range) \u2014\n // see bc1_fast_f16.wgsl.\n let gray = gd == 0.0;\n let lim_lo = select(bb_min, vec3<f32>(0.0), gray);\n let lim_hi = select(bb_max, vec3<f32>(1.0), gray);\n\n // Near-flat blocks (every channel within 3 levels): solid colour at the\n // block mean, per channel the endpoint pair whose \u2154/\u2153 interpolant lands\n // nearest; they share the index pass below and skip the seed + refits.\n let span = bb_max - bb_min;\n let flat = max(max(span.x, span.y), span.z) <= 3.0 / 255.0;\n var c0: u32;\n var c1: u32;\n if (flat) {\n let m8 = mean * 255.0;\n let pr = solid_pair(m8.x, 5u);\n let pg = solid_pair(m8.y, 6u);\n let pb = solid_pair(m8.z, 5u);\n let s0 = (pr.x << 11u) | (pg.x << 5u) | pb.x;\n let s1 = (pr.y << 11u) | (pg.y << 5u) | pb.y;\n c0 = max(s0, s1);\n c1 = min(s0, s1);\n } else {\n // Seed endpoints from the block's principal colour axis at the exact\n // projection extents, inset by ~half a 565 cell along the axis\n // (stb_dxt heuristic). Degenerate blocks keep the inset-bbox seed.\n var seed_hi: vec3<f32>;\n var seed_lo: vec3<f32>;\n let axis = principal_axis(&pixels, mean, bb_max - bb_min);\n if (dot(axis, axis) > 0.0) {\n var t_min: f32 = 1e30;\n var t_max: f32 = -1e30;\n for (var k: u32 = 0u; k < 16u; k = k + 1u) {\n let t = dot(pixels[k] - mean, axis);\n t_min = min(t_min, t);\n t_max = max(t_max, t);\n }\n let pad = (t_max - t_min) / 16.0;\n seed_hi = clamp(mean + (t_max - pad) * axis, vec3<f32>(0.0), vec3<f32>(1.0));\n seed_lo = clamp(mean + (t_min + pad) * axis, vec3<f32>(0.0), vec3<f32>(1.0));\n } else {\n let inset = (bb_max - bb_min) / 16.0;\n seed_hi = clamp(bb_max - inset, vec3<f32>(0.0), vec3<f32>(1.0));\n seed_lo = clamp(bb_min + inset, vec3<f32>(0.0), vec3<f32>(1.0));\n }\n let seed = order565(to565(seed_hi), to565(seed_lo));\n c0 = seed.x;\n c1 = seed.y;\n }\n\n // Projection pass on the seed, then up to two least-squares refit rounds\n // (solve() off the previous pass's moments), each re-projected and\n // accepted only if the block error drops \u2014 the refit minimises a\n // continuous objective and can lose after 565 quantisation. Equal flat\n // codes encode the colour itself: index 0 (opaque in either mode).\n var indices = 0u;\n if (c0 != c1) {\n var cur = moments(&pixels, c0, c1);\n for (var it: u32 = 0u; it < select(2u, 0u, flat); it = it + 1u) {\n let cand = solve(cur, c0, c1, lim_lo, lim_hi);\n if (cand.x == c0 && cand.y == c1) { break; }\n let nxt = moments(&pixels, cand.x, cand.y);\n if (nxt.err >= cur.err) { break; }\n c0 = cand.x;\n c1 = cand.y;\n cur = nxt;\n }\n indices = cur.indices;\n }\n\n let out = block_index * 2u;\n dst[out] = c0 | (c1 << 16u);\n dst[out + 1u] = indices;\n}\n";
972
1063
 
973
1064
  // src/bc1_fast_f16.wgsl
974
- var bc1_fast_f16_default = `// bc1 "fast" encoder \u2014 f16 variant (requires the shader-f16 feature).
975
- //
976
- // BC1 quantises endpoints to RGB565 anyway, so the fast path needs nothing
977
- // f32 can do that f16 can't \u2014 all projection / least-squares math runs in
978
- // f16 ([0,1] domain). The algorithm is the same family as the BC7/ASTC fast
979
- // paths rather than a port of bc1.wgsl's fast branch:
980
- //
981
- // 1. principal-axis endpoint seed (covariance power-iteration; inset bbox
982
- // on degenerate blocks), inset by ~half a 565 cell (stb_dxt heuristic)
983
- // 2. quantise to 565, force 4-colour mode (c0 > c1)
984
- // 3. ONE fused pass: project every pixel onto the decoded-endpoint line
985
- // (the 4 palette entries are colinear and evenly spaced, so the nearest
986
- // entry is the rounded projection \u2014 no 4-entry search) while
987
- // accumulating the least-squares refit sums, the packed indices and the
988
- // squared error
989
- // 4. up to TWO refit rounds (mirroring the high path's iterated refits):
990
- // re-quantise the refit endpoints, reproject (indices packed on the
991
- // fly, sums re-accumulated to seed the next round), and accept each
992
- // round only if the block error decreases \u2014 flat/single-level blocks
993
- // skip these passes entirely
994
- //
995
- // vs the pre-projection fast branch (build palette + full 4-entry search \xD7 3
996
- // passes + refit sums pass) this does roughly half the ALU per block. The
997
- // 565 decode uses exact integer math, so the palette base points are exact.
998
- //
999
- // The host selects this module only when the device reports shader-f16,
1000
- // falling back to bc1.wgsl otherwise. "high" never uses this.
1001
- enable f16;
1002
- struct Params { blocks_x: u32, blocks_y: u32, width: u32, height: u32, };
1003
- @group(0) @binding(0) var src_tex: texture_2d<f32>;
1004
- @group(0) @binding(1) var<storage, read_write> dst: array<u32>;
1005
- @group(0) @binding(2) var<uniform> params: Params;
1006
- alias h = f16;
1007
- alias h3 = vec3<f16>;
1008
-
1009
- fn to565(c: h3) -> u32 {
1010
- let r = u32(clamp(floor(c.r * h(31.0) + h(0.5)), h(0.0), h(31.0)));
1011
- let g = u32(clamp(floor(c.g * h(63.0) + h(0.5)), h(0.0), h(63.0)));
1012
- let b = u32(clamp(floor(c.b * h(31.0) + h(0.5)), h(0.0), h(31.0)));
1013
- return (r << 11u) | (g << 5u) | b;
1014
- }
1015
-
1016
- // Decode a 565 endpoint to [0,1]: (x*527+23)>>6 (6-bit: 259/33) \u2014
1017
- // round-to-nearest scaling, matching bc1_ref.ts / bc1.wgsl and typical
1018
- // hardware decoders. Exact in u32 integer math (f16 could not evaluate the
1019
- // products exactly).
1020
- fn from565(c: u32) -> h3 {
1021
- let r = (c >> 11u) & 31u;
1022
- let g = (c >> 5u) & 63u;
1023
- let b = c & 31u;
1024
- let r8 = (r * 527u + 23u) >> 6u;
1025
- let g8 = (g * 259u + 33u) >> 6u;
1026
- let b8 = (b * 527u + 23u) >> 6u;
1027
- return h3(vec3<f32>(vec3<u32>(r8, g8, b8))) * h(1.0 / 255.0);
1028
- }
1029
-
1030
- // Force 4-colour mode: c0 > c1 strictly.
1031
- fn order565(a: u32, b: u32) -> vec2<u32> {
1032
- var c0 = a; var c1 = b;
1033
- if (c0 == c1) {
1034
- if (c1 > 0u) { c1 = c1 - 1u; } else { c0 = c0 + 1u; }
1035
- } else if (c0 < c1) {
1036
- let t = c0; c0 = c1; c1 = t;
1037
- }
1038
- return vec2<u32>(c0, c1);
1039
- }
1040
-
1041
- // One projection pass against the decoded endpoints of (c0,c1): the packed
1042
- // 2-bit indices, the block's squared error, and the LSQ normal-equation sums
1043
- // of the resulting assignment \u2014 so an accepted refit can seed the next
1044
- // round. Levels s run 0..3 along p0\u2192p1 (palette = p0, p0+\u2153d, p0+\u2154d, p1 \u2014
1045
- // colinear, evenly spaced, so rounding the projection IS the nearest-entry
1046
- // search). Level \u2192 BC1 index: 0\u21920 (c0), 1\u21922 (\u2154c0+\u2153c1), 2\u21923, 3\u21921 (c1); as a
1047
- // packed LUT: (0x78 >> 2L) & 3.
1048
- struct Proj {
1049
- indices: u32,
1050
- err: h,
1051
- sAA: h, sBB: h, sAB: h,
1052
- sAV: h3, sBV: h3,
1053
- s_min: h, s_max: h,
1054
- };
1055
- fn project_stats(pix: ptr<function, array<h3, 16>>, c0: u32, c1: u32) -> Proj {
1056
- var out: Proj;
1057
- out.indices = 0u;
1058
- out.err = h(0.0);
1059
- out.sAA = h(0.0); out.sBB = h(0.0); out.sAB = h(0.0);
1060
- out.sAV = h3(0.0); out.sBV = h3(0.0);
1061
- out.s_min = h(3.0); out.s_max = h(0.0);
1062
- let p0 = from565(c0);
1063
- let p1 = from565(c1);
1064
- let dir = p1 - p0;
1065
- let dd = dot(dir, dir);
1066
- if (dd == h(0.0)) {
1067
- // Unreachable for distinct 565 codes (the decode is injective); kept so
1068
- // a degenerate call still returns a consistent error.
1069
- out.s_min = h(0.0);
1070
- for (var k: u32 = 0u; k < 16u; k = k + 1u) {
1071
- let e = (*pix)[k] - p0;
1072
- out.err = out.err + dot(e, e);
1073
- }
1074
- return out;
1075
- }
1076
- let inv = h(3.0) / dd;
1077
- for (var k: u32 = 0u; k < 16u; k = k + 1u) {
1078
- let v = (*pix)[k];
1079
- let s = clamp(floor(dot(v - p0, dir) * inv + h(0.5)), h(0.0), h(3.0));
1080
- out.s_min = min(out.s_min, s); out.s_max = max(out.s_max, s);
1081
- let b = s * h(1.0 / 3.0); let a = h(1.0) - b;
1082
- out.sAA = out.sAA + a * a; out.sBB = out.sBB + b * b; out.sAB = out.sAB + a * b;
1083
- out.sAV = out.sAV + a * v; out.sBV = out.sBV + b * v;
1084
- let e = v - (p0 + b * dir);
1085
- out.err = out.err + dot(e, e);
1086
- out.indices = out.indices | (((0x78u >> (u32(s) * 2u)) & 3u) << (k * 2u));
1087
- }
1088
- return out;
1089
- }
1090
-
1091
- @compute @workgroup_size(8, 8, 1)
1092
- fn encode(@builtin(global_invocation_id) gid: vec3<u32>) {
1093
- if (gid.x >= params.blocks_x || gid.y >= params.blocks_y) { return; }
1094
- let bi = gid.y * params.blocks_x + gid.x;
1095
- let base = vec2<i32>(i32(gid.x) * 4, i32(gid.y) * 4);
1096
- let mx = vec2<i32>(i32(params.width) - 1, i32(params.height) - 1);
1097
-
1098
- var pix: array<h3, 16>;
1099
- var mn = h3(1.0);
1100
- var mxv = h3(0.0);
1101
- var mean = h3(0.0);
1102
- var gd = h(0.0);
1103
- for (var i: u32 = 0u; i < 16u; i = i + 1u) {
1104
- let p = clamp(base + vec2<i32>(i32(i & 3u), i32(i >> 2u)), vec2<i32>(0), mx);
1105
- let px = h3(textureLoad(src_tex, p, 0).rgb);
1106
- pix[i] = px; mn = min(mn, px); mxv = max(mxv, px);
1107
- mean = mean + px;
1108
- gd = max(gd, max(abs(px.x - px.y), abs(px.x - px.z)));
1109
- }
1110
- mean = mean * h(1.0 / 16.0);
1111
- // Exactly-gray blocks free the refit from the bbox clamp below: a gray
1112
- // block has no hue to bend (the clamp's whole purpose), and on smooth
1113
- // gradients the LSQ optimum often lies OUTSIDE the data range \u2014 endpoints
1114
- // spread wider than the block so the 1/3-2/3 interpolants land on the
1115
- // values. Same rationale as the BC5 scalar channels (+0.32 dB there).
1116
- let gray = gd == h(0.0);
1117
- let lim_lo = select(mn, h3(0.0), gray);
1118
- let lim_hi = select(mxv, h3(1.0), gray);
1119
-
1120
- // Seed endpoints from the block's principal colour axis (covariance
1121
- // power-iteration, seeded with the bbox diagonal \u2014 same family as the
1122
- // 'high' path). The bbox diagonal is sign-blind: on anti-correlated
1123
- // channels (normal maps, hue edges) it points across the data instead of
1124
- // along it, the projection indices come out garbage, and the LSQ refit \u2014
1125
- // which fits endpoints GIVEN those indices \u2014 can't recover. Deviations are
1126
- // pre-scaled \xD716 so covariance entries for shallow blocks stay in f16's
1127
- // normal range (span ~1/255 \u2192 d\xB2 \u2248 1e-3) while full-range sums stay \u22644096;
1128
- // the iteration renormalises by the max component (a plain length() of the
1129
- // matvec output could overflow f16), so only the direction survives \u2014 the
1130
- // \xD7256 covariance scale is irrelevant.
1131
- var seed_hi: h3;
1132
- var seed_lo: h3;
1133
- var c0v = h3(0.0);
1134
- var c1v = h3(0.0);
1135
- var c2v = h3(0.0);
1136
- for (var k: u32 = 0u; k < 16u; k = k + 1u) {
1137
- let d = (pix[k] - mean) * h(16.0);
1138
- c0v = c0v + d.x * d;
1139
- c1v = c1v + d.y * d;
1140
- c2v = c2v + d.z * d;
1141
- }
1142
- var axis = mxv - mn;
1143
- var axis_ok = true;
1144
- for (var it: u32 = 0u; it < 4u; it = it + 1u) {
1145
- let nv = h3(dot(c0v, axis), dot(c1v, axis), dot(c2v, axis));
1146
- let m = max(max(abs(nv.x), abs(nv.y)), abs(nv.z));
1147
- if (m < h(1e-4)) { axis_ok = false; break; }
1148
- axis = nv / m;
1149
- }
1150
- if (axis_ok) {
1151
- axis = axis / length(axis);
1152
- var t_min = h(4.0);
1153
- var t_max = h(-4.0);
1154
- for (var k: u32 = 0u; k < 16u; k = k + 1u) {
1155
- let t = dot(pix[k] - mean, axis);
1156
- t_min = min(t_min, t);
1157
- t_max = max(t_max, t);
1158
- }
1159
- // Inset along the axis by ~half a 565 cell (stb_dxt heuristic, matching
1160
- // the degenerate-case bbox inset below).
1161
- let pad = (t_max - t_min) * h(1.0 / 16.0);
1162
- seed_hi = clamp(mean + (t_max - pad) * axis, h3(0.0), h3(1.0));
1163
- seed_lo = clamp(mean + (t_min + pad) * axis, h3(0.0), h3(1.0));
1164
- } else {
1165
- // Degenerate (near-flat) block: inset bbox seed, as before.
1166
- let inset = (mxv - mn) * h(1.0 / 16.0);
1167
- seed_hi = clamp(mxv - inset, h3(0.0), h3(1.0));
1168
- seed_lo = clamp(mn + inset, h3(0.0), h3(1.0));
1169
- }
1170
- let seed = order565(to565(seed_hi), to565(seed_lo));
1171
- var c0 = seed.x;
1172
- var c1 = seed.y;
1173
-
1174
- // Fused seed pass, then up to TWO least-squares refit rounds (mirroring
1175
- // the high path's iterated refits, at projection cost), each accepted only
1176
- // if the block's squared error actually decreases \u2014 the refit minimises a
1177
- // continuous objective and can lose after 565 quantisation. Every pass
1178
- // re-accumulates the normal-equation sums, so an accepted round seeds the
1179
- // next.
1180
- var cur = project_stats(&pix, c0, c1);
1181
- for (var it: u32 = 0u; it < 2u; it = it + 1u) {
1182
- // Refit only on a well-conditioned system. When every pixel lands on ONE
1183
- // level (flat / near-flat blocks \u2014 note the 4-colour-mode nudge forces
1184
- // c0 \u2260 c1 even for perfectly flat blocks) the system is rank-1: det is 0
1185
- // in exact math and the f16-accumulated det/numerators are pure rounding
1186
- // noise, so the solve returns garbage endpoints. With \u22652 distinct levels
1187
- // det = \u03A3_i<j (b_j \u2212 b_i)\xB2 \u2265 15\xB7(1/3)\xB2 \u2248 1.67, far above the ~0.05 f16
1188
- // noise floor \u2014 0.5 separates the two regimes cleanly.
1189
- if (cur.s_min >= cur.s_max) { break; }
1190
- let det = cur.sAA * cur.sBB - cur.sAB * cur.sAB;
1191
- if (abs(det) <= h(0.5)) { break; }
1192
- // Clamp the refit to the block bbox (not [0,1]) \u2014 except for exactly
1193
- // gray blocks, see the load pass: on multi-cluster blocks the
1194
- // unconstrained solve extrapolates far outside the block's colours
1195
- // and the per-channel clamp then bends the hue \u2014 fringe pixels decode to
1196
- // colours that exist nowhere in the block. Constraining to the bbox also
1197
- // measures better in plain SSE (+1.6 dB on the colour test card), so the
1198
- // accept-if-better guard below keeps more refits.
1199
- let e0 = clamp((cur.sBB * cur.sAV - cur.sAB * cur.sBV) / det, lim_lo, lim_hi);
1200
- let e1 = clamp((cur.sAA * cur.sBV - cur.sAB * cur.sAV) / det, lim_lo, lim_hi);
1201
- let rq = order565(to565(e0), to565(e1));
1202
- if (rq.x == c0 && rq.y == c1) { break; }
1203
- let nxt = project_stats(&pix, rq.x, rq.y);
1204
- if (nxt.err >= cur.err) { break; }
1205
- c0 = rq.x;
1206
- c1 = rq.y;
1207
- cur = nxt;
1208
- }
1209
-
1210
- let o = bi * 2u;
1211
- dst[o] = c0 | (c1 << 16u);
1212
- dst[o + 1u] = cur.indices;
1213
- }
1214
- `;
1065
+ var bc1_fast_f16_default = "// bc1 encoder \u2014 f16 variant (requires the shader-f16 feature).\n//\n// BC1 quantises endpoints to RGB565 anyway, so nothing here needs f32\n// precision except the refit sums (see moments()). Same algorithm as the\n// f32 fallback in bc1.wgsl:\n//\n// 1. NEAR-FLAT blocks (every channel within 3 levels) take a solid colour\n// at the block mean: per channel the endpoint pair whose \u2154/\u2153\n// interpolant lands nearest (solid_pair \u2014 the stb_dxt single-colour\n// idea). Direct 565 quantisation is up to 4 levels off on R/B there,\n// and a line fit has nothing to fit. +0.05..3.9 dB on content with\n// flat regions (displacement/AO maps, UI, the normal card).\n// 2. Otherwise a principal-axis endpoint seed (covariance power\n// iteration; inset bbox on degenerate blocks), inset by ~half a 565\n// cell (stb_dxt heuristic), quantised to 565 in 4-colour mode (c0 > c1).\n// 3. Projection passes: every pixel's level is the rounded projection\n// onto the decoded-endpoint line (the 4 palette entries are colinear\n// and evenly spaced, so that IS the nearest entry), packed as indices\n// on the fly, with the block error and projection MOMENTS accumulated.\n// 4. Up to TWO least-squares refit rounds solved from those moments\n// (solve()), each re-projected and accepted only if the block error\n// drops. The moments replace the full normal-equation sums a refit\n// used to accumulate per pixel (the refit rounds were ~60% of the\n// kernel): \u221210..15% GPU on non-flat content at equal quality.\n//\n// The host selects this module only when the device reports shader-f16,\n// falling back to bc1.wgsl otherwise.\nenable f16;\nstruct Params { blocks_x: u32, blocks_y: u32, width: u32, height: u32, y0: u32, };\n@group(0) @binding(0) var src_tex: texture_2d<f32>;\n@group(0) @binding(1) var<storage, read_write> dst: array<u32>;\n@group(0) @binding(2) var<uniform> params: Params;\nalias h = f16;\nalias h3 = vec3<f16>;\n\nfn to565(c: h3) -> u32 {\n let r = u32(clamp(floor(c.r * h(31.0) + h(0.5)), h(0.0), h(31.0)));\n let g = u32(clamp(floor(c.g * h(63.0) + h(0.5)), h(0.0), h(63.0)));\n let b = u32(clamp(floor(c.b * h(31.0) + h(0.5)), h(0.0), h(31.0)));\n return (r << 11u) | (g << 5u) | b;\n}\n\n// Decode a 565 endpoint to [0,1]: (x*527+23)>>6 (6-bit: 259/33) \u2014\n// round-to-nearest scaling, matching bc1_ref.ts / bc1.wgsl and typical\n// hardware decoders. Exact in u32 integer math (f16 could not evaluate the\n// products exactly).\nfn from565(c: u32) -> h3 {\n let r = (c >> 11u) & 31u;\n let g = (c >> 5u) & 63u;\n let b = c & 31u;\n let r8 = (r * 527u + 23u) >> 6u;\n let g8 = (g * 259u + 33u) >> 6u;\n let b8 = (b * 527u + 23u) >> 6u;\n return h3(vec3<f32>(vec3<u32>(r8, g8, b8))) * h(1.0 / 255.0);\n}\n\n// Force 4-colour mode: c0 > c1 strictly.\nfn order565(a: u32, b: u32) -> vec2<u32> {\n var c0 = a; var c1 = b;\n if (c0 == c1) {\n if (c1 > 0u) { c1 = c1 - 1u; } else { c0 = c0 + 1u; }\n } else if (c0 < c1) {\n let t = c0; c0 = c1; c1 = t;\n }\n return vec2<u32>(c0, c1);\n}\n\n// Projection MOMENTS against the decoded endpoints of (c0, c1): levels\n// L = 0..3 along p0\u2192p1 (the rounded projection \u2014 the palette is colinear\n// and evenly spaced, so that IS the nearest entry), their BC1 indices, and\n// \u03A3L, \u03A3L\xB2 (exact small integers), \u03A3u, \u03A3L\xB7u (u = v \u2212 p0, f32: the f16 block\n// mean is off by up to ~\xBD level \u2014 its running sum reaches ~8, where f16's\n// ulp is a whole level \u2014 which skews the closed-form solve by several\n// levels) \u2014 plus the block's exact squared error against this palette.\n// Level \u2192 BC1 index: 0\u21920 (c0), 1\u21922, 2\u21923, 3\u21921 (c1); packed LUT\n// (0x78 >> 2L) & 3.\nstruct Moments { sL: f32, sLL: f32, sU: vec3<f32>, sLu: vec3<f32>, indices: u32, err: f32 };\nfn moments(pix: ptr<function, array<h3, 16>>, c0: u32, c1: u32) -> Moments {\n let p0 = from565(c0);\n let dir = from565(c1) - p0;\n let inv = h(3.0) / dot(dir, dir);\n var sL = h(0.0);\n var sLL = h(0.0);\n var out: Moments;\n out.sU = vec3<f32>(0.0);\n out.sLu = vec3<f32>(0.0);\n out.indices = 0u;\n var err = h(0.0);\n for (var k: u32 = 0u; k < 16u; k = k + 1u) {\n let u = (*pix)[k] - p0;\n let L = clamp(floor(dot(u, dir) * inv + h(0.5)), h(0.0), h(3.0));\n sL = sL + L;\n sLL = sLL + L * L;\n let uf = vec3<f32>(u);\n out.sU = out.sU + uf;\n out.sLu = out.sLu + f32(L) * uf;\n out.indices = out.indices | (((0x78u >> (u32(L) * 2u)) & 3u) << (k * 2u));\n let e = u - L * h(1.0 / 3.0) * dir;\n err = err + dot(e, e);\n }\n out.err = f32(err);\n out.sL = f32(sL);\n out.sLL = f32(sLL);\n return out;\n}\n\n// One least-squares refit from moments: every normal-equation sum is an\n// O(1) function of them (b = L/3, a = 1 \u2212 b)\n// sBB = \u03A3L\xB2/9 sAB = \u03A3L/3 \u2212 \u03A3L\xB2/9 sAA = 16 \u2212 2\u03A3L/3 + \u03A3L\xB2/9\n// \u03A3b\xB7u = \u03A3L\xB7u/3 \u03A3a\xB7u = \u03A3u \u2212 \u03A3b\xB7u\n// solved in f32 for endpoints relative to p0, clamped to [lim_lo, lim_hi]\n// (the block bbox, except exactly-gray blocks \u2014 see the load pass: on\n// multi-cluster blocks the unconstrained solve extrapolates outside the\n// block's colours and the clamp would bend the hue), re-quantised and\n// ordered for 4-colour mode. Returns (c0, c1) unchanged when every pixel\n// sits on ONE level \u2014 then 16\xB7\u03A3L\xB2 == (\u03A3L)\xB2 exactly and the system is\n// singular.\nfn solve(m: Moments, c0: u32, c1: u32, lim_lo: h3, lim_hi: h3) -> vec2<u32> {\n if (16.0 * m.sLL == m.sL * m.sL) { return vec2<u32>(c0, c1); }\n let sBB = m.sLL * (1.0 / 9.0);\n let sAB = m.sL * (1.0 / 3.0) - sBB;\n let sAA = 16.0 - m.sL * (2.0 / 3.0) + sBB;\n let det = sAA * sBB - sAB * sAB;\n let p0 = vec3<f32>(from565(c0));\n let sBu = m.sLu * (1.0 / 3.0);\n let sAu = m.sU - sBu;\n let e0 = clamp(p0 + (sBB * sAu - sAB * sBu) / det, vec3<f32>(lim_lo), vec3<f32>(lim_hi));\n let e1 = clamp(p0 + (sAA * sBu - sAB * sAu) / det, vec3<f32>(lim_lo), vec3<f32>(lim_hi));\n return order565(to565(h3(e0)), to565(h3(e1)));\n}\n\n// Solid-colour channel code: the pair (a, b) of `bits`-bit codes whose \u2154/\u2153\n// interpolant (2\xB7dec(a) + dec(b))/3 \u2014 palette index 2 \u2014 lands nearest v\n// (8-bit units). With a == b it's a plain endpoint; straddling pairs reach\n// the ~2.7-level sub-steps between codes that direct quantisation (steps of\n// ~8 levels at 5 bits) cannot.\nfn solid_pair(v: f32, bits: u32) -> vec2<u32> {\n let maxc = (1u << bits) - 1u;\n let q = min(u32(v * f32(maxc) / 255.0), maxc - 1u);\n var x: f32; var y: f32;\n if (bits == 5u) {\n x = f32((q * 527u + 23u) >> 6u);\n y = f32(((q + 1u) * 527u + 23u) >> 6u);\n } else {\n x = f32((q * 259u + 33u) >> 6u);\n y = f32(((q + 1u) * 259u + 33u) >> 6u);\n }\n var best = vec2<u32>(q, q);\n var be = abs(x - v);\n let c1 = (2.0 * x + y) / 3.0;\n if (abs(c1 - v) < be) { be = abs(c1 - v); best = vec2<u32>(q, q + 1u); }\n let c2 = (x + 2.0 * y) / 3.0;\n if (abs(c2 - v) < be) { be = abs(c2 - v); best = vec2<u32>(q + 1u, q); }\n if (abs(y - v) < be) { best = vec2<u32>(q + 1u, q + 1u); }\n return best;\n}\n\n@compute @workgroup_size(8, 8, 1)\nfn encode(@builtin(global_invocation_id) gid_raw: vec3<u32>) {\n // Row-band encodes dispatch a slice of the block grid starting at row y0.\n let gid = vec3<u32>(gid_raw.x, gid_raw.y + params.y0, gid_raw.z);\n if (gid.x >= params.blocks_x || gid.y >= params.blocks_y) { return; }\n let bi = gid.y * params.blocks_x + gid.x;\n let base = vec2<i32>(i32(gid.x) * 4, i32(gid.y) * 4);\n let mx = vec2<i32>(i32(params.width) - 1, i32(params.height) - 1);\n\n var pix: array<h3, 16>;\n var mn = h3(1.0);\n var mxv = h3(0.0);\n var mean = h3(0.0);\n var gd = h(0.0);\n for (var i: u32 = 0u; i < 16u; i = i + 1u) {\n let p = clamp(base + vec2<i32>(i32(i & 3u), i32(i >> 2u)), vec2<i32>(0), mx);\n let px = h3(textureLoad(src_tex, p, 0).rgb);\n pix[i] = px; mn = min(mn, px); mxv = max(mxv, px);\n mean = mean + px;\n gd = max(gd, max(abs(px.x - px.y), abs(px.x - px.z)));\n }\n mean = mean * h(1.0 / 16.0);\n // Exactly-gray blocks free the refit from the bbox clamp below: a gray\n // block has no hue to bend (the clamp's whole purpose), and on smooth\n // gradients the LSQ optimum often lies OUTSIDE the data range \u2014 endpoints\n // spread wider than the block so the 1/3-2/3 interpolants land on the\n // values. Same rationale as the BC5 scalar channels (+0.32 dB there).\n let gray = gd == h(0.0);\n let lim_lo = select(mn, h3(0.0), gray);\n let lim_hi = select(mxv, h3(1.0), gray);\n\n // NEAR-FLAT blocks (every channel within 3 levels) take a solid colour\n // at the block mean: per channel the endpoint pair whose \u2154/\u2153 interpolant\n // lands nearest (see solid_pair). A line fit has nothing to fit there,\n // and direct 565 quantisation is up to 4 levels off on R/B. They share\n // the index pass below (pixels \xB11 level off the mean may sit closer to\n // an endpoint than to the interpolant) and skip the PCA seed and refits.\n let span = mxv - mn;\n let flat = max(max(span.x, span.y), span.z) <= h(3.0 / 255.0);\n var c0: u32;\n var c1: u32;\n if (flat) {\n let m8 = vec3<f32>(mean) * 255.0;\n let pr = solid_pair(m8.x, 5u);\n let pg = solid_pair(m8.y, 6u);\n let pb = solid_pair(m8.z, 5u);\n let s0 = (pr.x << 11u) | (pg.x << 5u) | pb.x;\n let s1 = (pr.y << 11u) | (pg.y << 5u) | pb.y;\n // c0 > c1 keeps 4-colour mode (index 2 = \u2154\xB7c0 + \u2153\xB7c1; swapped, the\n // same colour is index 3). Equal codes encode the colour itself.\n c0 = max(s0, s1);\n c1 = min(s0, s1);\n } else {\n // Seed endpoints from the block's principal colour axis (covariance\n // power-iteration, seeded with the bbox diagonal). The bbox diagonal is\n // sign-blind: on anti-correlated channels (normal maps, hue edges) it\n // points across the data instead of along it, the projection indices\n // come out garbage, and the LSQ refit \u2014 which fits endpoints GIVEN\n // those indices \u2014 can't recover. Deviations are pre-scaled \xD716 so\n // covariance entries for shallow blocks stay in f16's normal range\n // (span ~1/255 \u2192 d\xB2 \u2248 1e-3) while full-range sums stay \u22644096; the\n // iteration renormalises by the max component (a plain length() of the\n // matvec output could overflow f16), so only the direction survives.\n var seed_hi: h3;\n var seed_lo: h3;\n var c0v = h3(0.0);\n var c1v = h3(0.0);\n var c2v = h3(0.0);\n for (var k: u32 = 0u; k < 16u; k = k + 1u) {\n let d = (pix[k] - mean) * h(16.0);\n c0v = c0v + d.x * d;\n c1v = c1v + d.y * d;\n c2v = c2v + d.z * d;\n }\n var axis = mxv - mn;\n var axis_ok = true;\n for (var it: u32 = 0u; it < 4u; it = it + 1u) {\n let nv = h3(dot(c0v, axis), dot(c1v, axis), dot(c2v, axis));\n let m = max(max(abs(nv.x), abs(nv.y)), abs(nv.z));\n if (m < h(1e-4)) { axis_ok = false; break; }\n axis = nv / m;\n }\n if (axis_ok) {\n axis = axis / length(axis);\n var t_min = h(4.0);\n var t_max = h(-4.0);\n for (var k: u32 = 0u; k < 16u; k = k + 1u) {\n let t = dot(pix[k] - mean, axis);\n t_min = min(t_min, t);\n t_max = max(t_max, t);\n }\n // Inset along the axis by ~half a 565 cell (stb_dxt heuristic,\n // matching the degenerate-case bbox inset below).\n let pad = (t_max - t_min) * h(1.0 / 16.0);\n seed_hi = clamp(mean + (t_max - pad) * axis, h3(0.0), h3(1.0));\n seed_lo = clamp(mean + (t_min + pad) * axis, h3(0.0), h3(1.0));\n } else {\n // Degenerate block: inset bbox seed.\n let inset = (mxv - mn) * h(1.0 / 16.0);\n seed_hi = clamp(mxv - inset, h3(0.0), h3(1.0));\n seed_lo = clamp(mn + inset, h3(0.0), h3(1.0));\n }\n let seed = order565(to565(seed_hi), to565(seed_lo));\n c0 = seed.x;\n c1 = seed.y;\n }\n\n // Projection pass on the seed, then up to two refit rounds (solve() off\n // the previous pass's moments), each re-projected and accepted only if\n // the block error drops \u2014 the refit minimises a continuous objective and\n // can lose after 565 quantisation. Flat blocks keep their solid pair\n // (equal codes: the colour itself, index 0 \u2014 opaque in either mode).\n var indices = 0u;\n if (c0 != c1) {\n var cur = moments(&pix, c0, c1);\n for (var it: u32 = 0u; it < select(2u, 0u, flat); it = it + 1u) {\n let cand = solve(cur, c0, c1, lim_lo, lim_hi);\n if (cand.x == c0 && cand.y == c1) { break; }\n let nxt = moments(&pix, cand.x, cand.y);\n if (nxt.err >= cur.err) { break; }\n c0 = cand.x;\n c1 = cand.y;\n cur = nxt;\n }\n indices = cur.indices;\n }\n\n let o = bi * 2u;\n dst[o] = c0 | (c1 << 16u);\n dst[o + 1u] = indices;\n}\n";
1215
1066
 
1216
1067
  // src/BC1Encoder.ts
1217
1068
  var BC1Encoder = class extends Encoder {
@@ -1238,7 +1089,7 @@ var BC1Encoder = class extends Encoder {
1238
1089
  };
1239
1090
 
1240
1091
  // src/bc5.wgsl
1241
- var bc5_default = "// BC5 (RGTC2) compute shader encoder.\n//\n// Each invocation encodes one 4x4 pixel block into a 16-byte BC5 block\n// written as 4 x u32 into the destination storage buffer. This is the f32\n// fallback; bc5_fast_f16.wgsl is the same algorithm and is preferred when\n// the device reports shader-f16.\n//\n// BC5 = two BC4 blocks concatenated:\n// block bytes 0..7 : BC4 of R channel (normal.x for tangent-space normals)\n// block bytes 8..15 : BC4 of G channel (normal.y)\n//\n// Each BC4 half-block (8 bytes):\n// byte 0 : red0 (8-bit endpoint)\n// byte 1 : red1 (8-bit endpoint)\n// bytes 2..7 : 16 \xD7 3-bit indices, LSB-first, pixel 0 at bit 0\n//\n// We always produce the 6-interpolation mode (red0 > red1). See `bc4_ref.ts`\n// for the reference this encoder is validated against.\n//\n// Same algorithm as bc5_fast_f16.wgsl (see that file for the full notes):\n// \u2022 both channels processed as vec2 lanes of the fused passes;\n// \u2022 pass 1 accumulates MOMENTS (\u03A3L, \u03A3L\xB2, \u03A3\u03C1, \u03A3L\u03C1) from which every LSQ\n// normal-equation sum is an O(1) per-block expression; the rank guard\n// is the exact 16\xB7\u03A3L\xB2 == (\u03A3L)\xB2 test;\n// \u2022 the closed-form refit prices the nearest rounding of the solve\n// through E(\u03B4) = err \u2212 2(\u03B40\xB7sAR + \u03B41\xB7sBR) + \u03B40\xB2sAA + 2\u03B40\u03B41\xB7sAB\n// + \u03B41\xB2sBB, accept-if-better;\n// \u2022 pass 2 packs the indices ONCE, against the FINAL endpoints \u2014 full\n// reprojection quality at parity cost;\n// \u2022 the 16 texel reads are 8 textureGather fetches (4 quads \xD7 R,G)\n// through a clamp-to-edge sampler, byte-identical to per-texel loads;\n// \u2022 3-bit indices accumulate branch-free into two 24-bit words (pixels\n// 0..7 and 8..15) recombined with constant shifts \u2014 no per-pixel\n// straddle branches.\n// Values are kept in the [0,255] f32 domain throughout.\n//\n// Level \u2192 BC4 index LUT (0,2,3,4,5,6,7,1) packed as 3-bit entries in\n// 0x3F58D0.\n\nstruct Params {\n blocks_x: u32,\n blocks_y: u32,\n width: u32,\n height: u32,\n};\n\n@group(0) @binding(0) var src_tex: texture_2d<f32>;\n@group(0) @binding(1) var<storage, read_write> dst: array<u32>;\n@group(0) @binding(2) var<uniform> params: Params;\n@group(0) @binding(3) var smp: sampler;\n\nconst IDX_LUT: u32 = 0x3F58D0u;\n\n// Closed-form accept-if-better endpoint refinement for one channel \u2014 same\n// as the f16 module's `refine` (both run this per-block step in f32).\n// Endpoints clamp to [0,255], NOT the block's value range: for a scalar\n// channel, endpoints beyond the data range are often genuinely optimal and\n// there is no colour axis to bend.\nfn refine(sAA: f32, sBB: f32, sAB: f32, sAR: f32, sBR: f32, b0: u32, b1: u32, spread: bool) -> vec2<u32> {\n var out = vec2<u32>(b0, b1);\n let det = sAA * sBB - sAB * sAB;\n if (!spread || abs(det) <= 1e-3) { return out; }\n let b0f = f32(b0);\n let b1f = f32(b1);\n let e0 = clamp(b0f + (sBB * sAR - sAB * sBR) / det, 0.0, 255.0);\n let e1 = clamp(b1f + (sAA * sBR - sAB * sAR) / det, 0.0, 255.0);\n let q0f = floor(e0 + 0.5);\n let q1f = floor(e1 + 0.5);\n let q0 = u32(q0f);\n let q1 = u32(q1f);\n if (q0 > q1 && !(q0 == b0 && q1 == b1)) {\n let dd0 = q0f - b0f;\n let dd1 = q1f - b1f;\n let eNew = -2.0 * (dd0 * sAR + dd1 * sBR)\n + dd0 * dd0 * sAA + 2.0 * dd0 * dd1 * sAB + dd1 * dd1 * sBB;\n if (eNew < 0.0) {\n out = vec2<u32>(q0, q1);\n }\n }\n return out;\n}\n\n@compute @workgroup_size(8, 8, 1)\nfn encode(@builtin(global_invocation_id) gid: vec3<u32>) {\n if (gid.x >= params.blocks_x || gid.y >= params.blocks_y) {\n return;\n }\n let bi = gid.y * params.blocks_x + gid.x;\n let base = vec2<i32>(i32(gid.x) * 4, i32(gid.y) * 4);\n\n // Load 4\xD74 R/G pairs (x = R, y = G throughout), min/max fused in.\n // Interior blocks read via 8 gathers normalised by the PHYSICAL (padded)\n // texture size; blocks straddling the source edge of a non-multiple-of-4\n // image fall back to per-texel loads clamped to the last real texel (the\n // padding strip is zero-initialised \u2014 see bc5_fast_f16.wgsl).\n // Gather components: w=(0,0) z=(1,0) x=(0,1) y=(1,1) within each quad.\n var v: array<vec2<f32>, 16>;\n var vmin = vec2<f32>(255.0);\n var vmax = vec2<f32>(0.0);\n if (u32(base.x) + 4u <= params.width && u32(base.y) + 4u <= params.height) {\n let inv_size = vec2<f32>(1.0, 1.0) / vec2<f32>(textureDimensions(src_tex));\n for (var q: u32 = 0u; q < 4u; q = q + 1u) {\n let qo = vec2<u32>((q & 1u) * 2u, (q >> 1u) * 2u);\n let cc = (vec2<f32>(base) + vec2<f32>(qo) + vec2<f32>(1.0, 1.0)) * inv_size;\n let r4 = textureGather(0, src_tex, smp, cc) * 255.0;\n let g4 = textureGather(1, src_tex, smp, cc) * 255.0;\n let i = qo.y * 4u + qo.x;\n let vw = vec2<f32>(r4.w, g4.w);\n let vz = vec2<f32>(r4.z, g4.z);\n let vx = vec2<f32>(r4.x, g4.x);\n let vy = vec2<f32>(r4.y, g4.y);\n v[i] = vw; v[i + 1u] = vz; v[i + 4u] = vx; v[i + 5u] = vy;\n vmin = min(min(vmin, min(vw, vz)), min(vx, vy));\n vmax = max(max(vmax, max(vw, vz)), max(vx, vy));\n }\n } else {\n let mx = vec2<i32>(i32(params.width) - 1, i32(params.height) - 1);\n for (var i: u32 = 0u; i < 16u; i = i + 1u) {\n let p = clamp(base + vec2<i32>(i32(i & 3u), i32(i >> 2u)), vec2<i32>(0), mx);\n let c = textureLoad(src_tex, p, 0);\n let val = vec2<f32>(c.r, c.g) * 255.0;\n v[i] = val; vmin = min(vmin, val); vmax = max(vmax, val);\n }\n }\n\n // Seed endpoints at the exact per-channel extremes (round-to-nearest, the\n // same rule the CPU reference uses). Flat blocks get nudged apart to keep\n // the 6-interp mode (r0 > r1 strictly).\n var r0 = vec2<u32>(clamp(floor(vmax + 0.5), vec2<f32>(0.0), vec2<f32>(255.0)));\n var r1 = vec2<u32>(clamp(floor(vmin + 0.5), vec2<f32>(0.0), vec2<f32>(255.0)));\n if (r0.x == r1.x) { if (r1.x > 0u) { r1.x = r1.x - 1u; } else { r0.x = r0.x + 1u; } }\n if (r0.y == r1.y) { if (r1.y > 0u) { r1.y = r1.y - 1u; } else { r0.y = r0.y + 1u; } }\n\n let r0f = vec2<f32>(r0);\n let r1f = vec2<f32>(r1);\n let dir = r1f - r0f;\n let scale = vec2<f32>(7.0) / dir;\n\n // Pass 1, both channels \u2014 MOMENTS only. t = 7(v\u2212r0)/(r1\u2212r0) \u2208 [0,7]\n // (the seed covers the data), L = round(t), \u03C1 = t \u2212 L.\n var sL = vec2<f32>(0.0); var sLL = vec2<f32>(0.0);\n var pR = vec2<f32>(0.0); var pLR = vec2<f32>(0.0);\n for (var k: u32 = 0u; k < 16u; k = k + 1u) {\n let t = (v[k] - r0f) * scale;\n let L = clamp(floor(t + 0.5), vec2<f32>(0.0), vec2<f32>(7.0));\n let rho = t - L;\n sL = sL + L; sLL = sLL + L * L;\n pR = pR + rho; pLR = pLR + L * rho;\n }\n\n // Per-block refit off the moments (see bc5_fast_f16.wgsl for the\n // identities).\n let sBB = sLL * (1.0 / 49.0);\n let sAB = sL * (1.0 / 7.0) - sBB;\n let sAA = vec2<f32>(16.0) - 2.0 * sL * (1.0 / 7.0) + sBB;\n let sBR = pLR * dir * (1.0 / 49.0);\n let sAR = (pR - pLR * (1.0 / 7.0)) * dir * (1.0 / 7.0);\n let spread = 16.0 * sLL != sL * sL;\n\n let fx = refine(sAA.x, sBB.x, sAB.x, sAR.x, sBR.x, r0.x, r1.x, spread.x);\n let fy = refine(sAA.y, sBB.y, sAB.y, sAR.y, sBR.y, r0.y, r1.y, spread.y);\n let n0 = vec2<u32>(fx.x, fy.x);\n let n1 = vec2<u32>(fx.y, fy.y);\n\n // Pass 2, both channels \u2014 pack the shipped indices against the FINAL\n // endpoints (rejected channels re-derive their seed assignment). iA\n // holds pixels 0..7 (3 bits each), iB pixels 8..15.\n let n0f = vec2<f32>(n0);\n let n1f = vec2<f32>(n1);\n let scale2 = vec2<f32>(7.0) / (n1f - n0f);\n var iAx = 0u; var iBx = 0u; var iAy = 0u; var iBy = 0u;\n for (var k: u32 = 0u; k < 8u; k = k + 1u) {\n let L = clamp(floor((v[k] - n0f) * scale2 + 0.5), vec2<f32>(0.0), vec2<f32>(7.0));\n iAx = iAx | (((IDX_LUT >> (u32(L.x) * 3u)) & 7u) << (k * 3u));\n iAy = iAy | (((IDX_LUT >> (u32(L.y) * 3u)) & 7u) << (k * 3u));\n }\n for (var k: u32 = 8u; k < 16u; k = k + 1u) {\n let L = clamp(floor((v[k] - n0f) * scale2 + 0.5), vec2<f32>(0.0), vec2<f32>(7.0));\n iBx = iBx | (((IDX_LUT >> (u32(L.x) * 3u)) & 7u) << ((k - 8u) * 3u));\n iBy = iBy | (((IDX_LUT >> (u32(L.y) * 3u)) & 7u) << ((k - 8u) * 3u));\n }\n\n // BC5 block = R half (bytes 0..7) || G half (bytes 8..15) = 4 u32s.\n let o = bi * 4u;\n dst[o] = n0.x | (n1.x << 8u) | (iAx << 16u);\n dst[o + 1u] = (iAx >> 16u) | (iBx << 8u);\n dst[o + 2u] = n0.y | (n1.y << 8u) | (iAy << 16u);\n dst[o + 3u] = (iAy >> 16u) | (iBy << 8u);\n}\n";
1092
+ var bc5_default = "// BC5 (RGTC2) compute shader encoder.\n//\n// Each invocation encodes one 4x4 pixel block into a 16-byte BC5 block\n// written as 4 x u32 into the destination storage buffer. This is the f32\n// fallback; bc5_fast_f16.wgsl is the same algorithm and is preferred when\n// the device reports shader-f16.\n//\n// BC5 = two BC4 blocks concatenated:\n// block bytes 0..7 : BC4 of R channel (normal.x for tangent-space normals)\n// block bytes 8..15 : BC4 of G channel (normal.y)\n//\n// Each BC4 half-block (8 bytes):\n// byte 0 : red0 (8-bit endpoint)\n// byte 1 : red1 (8-bit endpoint)\n// bytes 2..7 : 16 \xD7 3-bit indices, LSB-first, pixel 0 at bit 0\n//\n// We always produce the 6-interpolation mode (red0 > red1). See `bc4_ref.ts`\n// for the reference this encoder is validated against.\n//\n// Same algorithm as bc5_fast_f16.wgsl (see that file for the full notes):\n// \u2022 both channels processed as vec2 lanes of the fused passes;\n// \u2022 pass 1 accumulates MOMENTS (\u03A3L, \u03A3L\xB2, \u03A3\u03C1, \u03A3L\u03C1) from which every LSQ\n// normal-equation sum is an O(1) per-block expression; the rank guard\n// is the exact 16\xB7\u03A3L\xB2 == (\u03A3L)\xB2 test;\n// \u2022 the closed-form refit prices the nearest rounding of the solve\n// through E(\u03B4) = err \u2212 2(\u03B40\xB7sAR + \u03B41\xB7sBR) + \u03B40\xB2sAA + 2\u03B40\u03B41\xB7sAB\n// + \u03B41\xB2sBB, accept-if-better;\n// \u2022 pass 2 packs the indices ONCE, against the FINAL endpoints \u2014 full\n// reprojection quality at parity cost;\n// \u2022 the 16 texel reads are 8 textureGather fetches (4 quads \xD7 R,G)\n// through a clamp-to-edge sampler, byte-identical to per-texel loads;\n// \u2022 3-bit indices accumulate branch-free into two 24-bit words (pixels\n// 0..7 and 8..15) recombined with constant shifts \u2014 no per-pixel\n// straddle branches.\n// Values are kept in the [0,255] f32 domain throughout.\n//\n// Level \u2192 BC4 index LUT (0,2,3,4,5,6,7,1) packed as 3-bit entries in\n// 0x3F58D0.\n\nstruct Params {\n blocks_x: u32,\n blocks_y: u32,\n width: u32,\n height: u32,\n y0: u32, // first block row of this dispatch (row-band encodes)\n};\n\n@group(0) @binding(0) var src_tex: texture_2d<f32>;\n@group(0) @binding(1) var<storage, read_write> dst: array<u32>;\n@group(0) @binding(2) var<uniform> params: Params;\n@group(0) @binding(3) var smp: sampler;\n\nconst IDX_LUT: u32 = 0x3F58D0u;\n\n// Closed-form accept-if-better endpoint refinement for one channel \u2014 same\n// as the f16 module's `refine` (both run this per-block step in f32).\n// Endpoints clamp to [0,255], NOT the block's value range: for a scalar\n// channel, endpoints beyond the data range are often genuinely optimal and\n// there is no colour axis to bend.\nfn refine(sAA: f32, sBB: f32, sAB: f32, sAR: f32, sBR: f32, b0: u32, b1: u32, spread: bool) -> vec2<u32> {\n var out = vec2<u32>(b0, b1);\n let det = sAA * sBB - sAB * sAB;\n if (!spread || abs(det) <= 1e-3) { return out; }\n let b0f = f32(b0);\n let b1f = f32(b1);\n let e0 = clamp(b0f + (sBB * sAR - sAB * sBR) / det, 0.0, 255.0);\n let e1 = clamp(b1f + (sAA * sBR - sAB * sAR) / det, 0.0, 255.0);\n let q0f = floor(e0 + 0.5);\n let q1f = floor(e1 + 0.5);\n let q0 = u32(q0f);\n let q1 = u32(q1f);\n if (q0 > q1 && !(q0 == b0 && q1 == b1)) {\n let dd0 = q0f - b0f;\n let dd1 = q1f - b1f;\n let eNew = -2.0 * (dd0 * sAR + dd1 * sBR)\n + dd0 * dd0 * sAA + 2.0 * dd0 * dd1 * sAB + dd1 * dd1 * sBB;\n if (eNew < 0.0) {\n out = vec2<u32>(q0, q1);\n }\n }\n return out;\n}\n\n@compute @workgroup_size(8, 8, 1)\nfn encode(@builtin(global_invocation_id) gid_raw: vec3<u32>) {\n // Row-band encodes dispatch a slice of the block grid starting at row y0.\n let gid = vec3<u32>(gid_raw.x, gid_raw.y + params.y0, gid_raw.z);\n if (gid.x >= params.blocks_x || gid.y >= params.blocks_y) {\n return;\n }\n let bi = gid.y * params.blocks_x + gid.x;\n let base = vec2<i32>(i32(gid.x) * 4, i32(gid.y) * 4);\n\n // Load 4\xD74 R/G pairs (x = R, y = G throughout), min/max fused in.\n // Interior blocks read via 8 gathers normalised by the PHYSICAL (padded)\n // texture size; blocks straddling the source edge of a non-multiple-of-4\n // image fall back to per-texel loads clamped to the last real texel (the\n // padding strip is zero-initialised \u2014 see bc5_fast_f16.wgsl).\n // Gather components: w=(0,0) z=(1,0) x=(0,1) y=(1,1) within each quad.\n var v: array<vec2<f32>, 16>;\n var vmin = vec2<f32>(255.0);\n var vmax = vec2<f32>(0.0);\n if (u32(base.x) + 4u <= params.width && u32(base.y) + 4u <= params.height) {\n let inv_size = vec2<f32>(1.0, 1.0) / vec2<f32>(textureDimensions(src_tex));\n for (var q: u32 = 0u; q < 4u; q = q + 1u) {\n let qo = vec2<u32>((q & 1u) * 2u, (q >> 1u) * 2u);\n let cc = (vec2<f32>(base) + vec2<f32>(qo) + vec2<f32>(1.0, 1.0)) * inv_size;\n let r4 = textureGather(0, src_tex, smp, cc) * 255.0;\n let g4 = textureGather(1, src_tex, smp, cc) * 255.0;\n let i = qo.y * 4u + qo.x;\n let vw = vec2<f32>(r4.w, g4.w);\n let vz = vec2<f32>(r4.z, g4.z);\n let vx = vec2<f32>(r4.x, g4.x);\n let vy = vec2<f32>(r4.y, g4.y);\n v[i] = vw; v[i + 1u] = vz; v[i + 4u] = vx; v[i + 5u] = vy;\n vmin = min(min(vmin, min(vw, vz)), min(vx, vy));\n vmax = max(max(vmax, max(vw, vz)), max(vx, vy));\n }\n } else {\n let mx = vec2<i32>(i32(params.width) - 1, i32(params.height) - 1);\n for (var i: u32 = 0u; i < 16u; i = i + 1u) {\n let p = clamp(base + vec2<i32>(i32(i & 3u), i32(i >> 2u)), vec2<i32>(0), mx);\n let c = textureLoad(src_tex, p, 0);\n let val = vec2<f32>(c.r, c.g) * 255.0;\n v[i] = val; vmin = min(vmin, val); vmax = max(vmax, val);\n }\n }\n\n // Seed endpoints at the exact per-channel extremes (round-to-nearest, the\n // same rule the CPU reference uses). Flat blocks get nudged apart to keep\n // the 6-interp mode (r0 > r1 strictly).\n var r0 = vec2<u32>(clamp(floor(vmax + 0.5), vec2<f32>(0.0), vec2<f32>(255.0)));\n var r1 = vec2<u32>(clamp(floor(vmin + 0.5), vec2<f32>(0.0), vec2<f32>(255.0)));\n if (r0.x == r1.x) { if (r1.x > 0u) { r1.x = r1.x - 1u; } else { r0.x = r0.x + 1u; } }\n if (r0.y == r1.y) { if (r1.y > 0u) { r1.y = r1.y - 1u; } else { r0.y = r0.y + 1u; } }\n\n let r0f = vec2<f32>(r0);\n let r1f = vec2<f32>(r1);\n let dir = r1f - r0f;\n let scale = vec2<f32>(7.0) / dir;\n\n // Pass 1, both channels \u2014 MOMENTS only. t = 7(v\u2212r0)/(r1\u2212r0) \u2208 [0,7]\n // (the seed covers the data), L = round(t), \u03C1 = t \u2212 L.\n var sL = vec2<f32>(0.0); var sLL = vec2<f32>(0.0);\n var pR = vec2<f32>(0.0); var pLR = vec2<f32>(0.0);\n for (var k: u32 = 0u; k < 16u; k = k + 1u) {\n let t = (v[k] - r0f) * scale;\n let L = clamp(floor(t + 0.5), vec2<f32>(0.0), vec2<f32>(7.0));\n let rho = t - L;\n sL = sL + L; sLL = sLL + L * L;\n pR = pR + rho; pLR = pLR + L * rho;\n }\n\n // Per-block refit off the moments (see bc5_fast_f16.wgsl for the\n // identities).\n let sBB = sLL * (1.0 / 49.0);\n let sAB = sL * (1.0 / 7.0) - sBB;\n let sAA = vec2<f32>(16.0) - 2.0 * sL * (1.0 / 7.0) + sBB;\n let sBR = pLR * dir * (1.0 / 49.0);\n let sAR = (pR - pLR * (1.0 / 7.0)) * dir * (1.0 / 7.0);\n let spread = 16.0 * sLL != sL * sL;\n\n let fx = refine(sAA.x, sBB.x, sAB.x, sAR.x, sBR.x, r0.x, r1.x, spread.x);\n let fy = refine(sAA.y, sBB.y, sAB.y, sAR.y, sBR.y, r0.y, r1.y, spread.y);\n let n0 = vec2<u32>(fx.x, fy.x);\n let n1 = vec2<u32>(fx.y, fy.y);\n\n // Pass 2, both channels \u2014 pack the shipped indices against the FINAL\n // endpoints (rejected channels re-derive their seed assignment). iA\n // holds pixels 0..7 (3 bits each), iB pixels 8..15.\n let n0f = vec2<f32>(n0);\n let n1f = vec2<f32>(n1);\n let scale2 = vec2<f32>(7.0) / (n1f - n0f);\n var iAx = 0u; var iBx = 0u; var iAy = 0u; var iBy = 0u;\n for (var k: u32 = 0u; k < 8u; k = k + 1u) {\n let L = clamp(floor((v[k] - n0f) * scale2 + 0.5), vec2<f32>(0.0), vec2<f32>(7.0));\n iAx = iAx | (((IDX_LUT >> (u32(L.x) * 3u)) & 7u) << (k * 3u));\n iAy = iAy | (((IDX_LUT >> (u32(L.y) * 3u)) & 7u) << (k * 3u));\n }\n for (var k: u32 = 8u; k < 16u; k = k + 1u) {\n let L = clamp(floor((v[k] - n0f) * scale2 + 0.5), vec2<f32>(0.0), vec2<f32>(7.0));\n iBx = iBx | (((IDX_LUT >> (u32(L.x) * 3u)) & 7u) << ((k - 8u) * 3u));\n iBy = iBy | (((IDX_LUT >> (u32(L.y) * 3u)) & 7u) << ((k - 8u) * 3u));\n }\n\n // BC5 block = R half (bytes 0..7) || G half (bytes 8..15) = 4 u32s.\n let o = bi * 4u;\n dst[o] = n0.x | (n1.x << 8u) | (iAx << 16u);\n dst[o + 1u] = (iAx >> 16u) | (iBx << 8u);\n dst[o + 2u] = n0.y | (n1.y << 8u) | (iAy << 16u);\n dst[o + 3u] = (iAy >> 16u) | (iBy << 8u);\n}\n";
1242
1093
 
1243
1094
  // src/bc5_fast_f16.wgsl
1244
1095
  var bc5_fast_f16_default = `// bc5 "fast" encoder \u2014 f16 variant (requires the shader-f16 feature).
@@ -1314,7 +1165,7 @@ var bc5_fast_f16_default = `// bc5 "fast" encoder \u2014 f16 variant (requires t
1314
1165
  enable f16;
1315
1166
  alias h = f16;
1316
1167
  alias h2 = vec2<f16>;
1317
- struct Params { blocks_x: u32, blocks_y: u32, width: u32, height: u32, };
1168
+ struct Params { blocks_x: u32, blocks_y: u32, width: u32, height: u32, y0: u32, };
1318
1169
  @group(0) @binding(0) var src_tex: texture_2d<f32>;
1319
1170
  @group(0) @binding(1) var<storage, read_write> dst: array<u32>;
1320
1171
  @group(0) @binding(2) var<uniform> params: Params;
@@ -1356,7 +1207,9 @@ fn refine(sAA: f32, sBB: f32, sAB: f32, sAR: f32, sBR: f32, b0: u32, b1: u32, sp
1356
1207
  }
1357
1208
 
1358
1209
  @compute @workgroup_size(8, 8, 1)
1359
- fn encode(@builtin(global_invocation_id) gid: vec3<u32>) {
1210
+ fn encode(@builtin(global_invocation_id) gid_raw: vec3<u32>) {
1211
+ // Row-band encodes dispatch a slice of the block grid starting at row y0.
1212
+ let gid = vec3<u32>(gid_raw.x, gid_raw.y + params.y0, gid_raw.z);
1360
1213
  if (gid.x >= params.blocks_x || gid.y >= params.blocks_y) { return; }
1361
1214
  let bi = gid.y * params.blocks_x + gid.x;
1362
1215
  let base = vec2<i32>(i32(gid.x) * 4, i32(gid.y) * 4);
@@ -1500,10 +1353,10 @@ var BC5Encoder = class extends Encoder {
1500
1353
  };
1501
1354
 
1502
1355
  // src/bc7.wgsl
1503
- var bc7_default = "// BC7 (BPTC) mode 6 compute shader encoder.\n//\n// One invocation per 4\xD74 block. Emits 16 bytes = 4 u32s into the storage\n// buffer at `dst[block_index * 4 .. + 3]`. This is the f32 fallback;\n// bc7_fast_f16.wgsl is the same algorithm and is preferred when the device\n// reports shader-f16.\n//\n// ALGORITHM: principal-axis seed (covariance power-iteration; bbox on\n// degenerate blocks) at the exact projection extents, quantised directly \u2014\n// no LSQ refit; with the seed on the principal axis, mode 6's 16-level\n// palette leaves the refit under 0.15 dB, unlike the 4-level BC1/ASTC\n// encoders which keep theirs \u2014 then one pass that projects each pixel onto\n// the endpoint line (the 16 palette entries are colinear, so the nearest\n// index is the rounded projection \u2014 no palette build, no 16-entry search),\n// packed on the fly into two nibble words.\n//\n// A MODE 1 (2-subset) candidate was built and evaluated (2026-07) and\n// dropped: ~+1.3 dB on multi-modal content but up to ~3\xD7 the pass cost on\n// exactly that content \u2014 see bc7_fast_f16.wgsl. The CPU reference decoder\n// keeps mode 1 support (bc7_ref.ts).\n//\n// MODE 6 LAYOUT (LSB-first, bit 0 = byte 0's bit 0)\n// bits 0..6 mode field (0b0000001 \u2014 only bit 6 is 1)\n// bits 7..13 R0 (7-bit) bits 14..20 R1 bits 21..27 G0 bits 28..34 G1\n// bits 35..41 B0 bits 42..48 B1 bits 49..55 A0 bits 56..62 A1\n// bit 63 P0 bit 64 P1\n// bits 65..67 pixel 0 index (3 bits; anchor, MSB implicit 0)\n// bits 68..71 pixel 1 index (4 bits) ... bits 124..127 pixel 15 index\n//\n// Effective 8-bit endpoint channel = (7_bit_value << 1) | p_bit.\n// Palette[i] = ((64 \u2212 W4[i]) \xD7 e0_8 + W4[i] \xD7 e1_8 + 32) >> 6, integer.\n//\n// The block is assembled with straight-line constant shifts (see the layout\n// summary in bc7_fast_f16.wgsl) \u2014 a generic write_bits() helper's dynamic\n// word indexing keeps the output array out of registers.\n\nstruct Params {\n blocks_x: u32,\n blocks_y: u32,\n width: u32,\n height: u32,\n};\n\n@group(0) @binding(0) var src_tex: texture_2d<f32>;\n@group(0) @binding(1) var<storage, read_write> dst: array<u32>;\n@group(0) @binding(2) var<uniform> params: Params;\n\nfn to8(v: vec4<f32>) -> vec4<i32> {\n return vec4<i32>(clamp(floor(v * 255.0 + 0.5), vec4<f32>(0.0), vec4<f32>(255.0)));\n}\n\nfn dist2(a: vec4<i32>, b: vec4<i32>) -> i32 {\n let d = a - b;\n let e = d * d;\n return e.x + e.y + e.z + e.w;\n}\n\n// Quantize an 8-bit ideal endpoint to (7-bit value, reconstructed 8-bit)\n// under a fixed p-bit, all four channels at once. q7 = round((ideal8 \u2212 p)/2).\nstruct QuantPair { seven: vec4<i32>, eight: vec4<i32> };\nfn quantize_endpoint(ideal8: vec4<i32>, p: u32) -> QuantPair {\n let q = vec4<i32>(clamp(\n floor((vec4<f32>(ideal8) - f32(p)) / 2.0 + 0.5),\n vec4<f32>(0.0), vec4<f32>(127.0),\n ));\n let eff = (q << vec4<u32>(1u)) | vec4<i32>(i32(p));\n return QuantPair(q, eff);\n}\n\n// Endpoint with its chosen p-bit, picked by minimum quantisation error.\nstruct Ep { seven: vec4<i32>, eight: vec4<i32>, p: u32 };\nfn pick_ep(ideal: vec4<i32>) -> Ep {\n let a = quantize_endpoint(ideal, 0u);\n let b = quantize_endpoint(ideal, 1u);\n if (dist2(b.eight, ideal) < dist2(a.eight, ideal)) { return Ep(b.seven, b.eight, 1u); }\n return Ep(a.seven, a.eight, 0u);\n}\n\n// Principal colour axis via power-iteration over precomputed, mean-corrected\n// covariance rows (the moments are accumulated for free in the pixel-load\n// loop), seeded with the bbox diagonal. Returns a unit axis, or vec4(0) for\n// a degenerate (constant) block. Same family as bc1.wgsl's principal_axis \u2014\n// the bbox diagonal alone is sign-blind and points across anti-correlated\n// data (normal maps, hue edges) instead of along it.\nfn principal_axis4(\n c0v: vec4<f32>,\n c1v: vec4<f32>,\n c2v: vec4<f32>,\n c3v: vec4<f32>,\n seed: vec4<f32>,\n) -> vec4<f32> {\n var v = seed;\n var len = length(v);\n if (len < 1e-9) { return vec4<f32>(0.0); }\n v = v / len;\n for (var iter: u32 = 0u; iter < 8u; iter = iter + 1u) {\n let nv = vec4<f32>(dot(c0v, v), dot(c1v, v), dot(c2v, v), dot(c3v, v));\n len = length(nv);\n if (len < 1e-12) { return vec4<f32>(0.0); }\n v = nv / len;\n }\n return v;\n}\n\n\n// ------------------------------- Entry --------------------------------- //\n\n@compute @workgroup_size(8, 8, 1)\nfn encode(@builtin(global_invocation_id) gid: vec3<u32>) {\n if (gid.x >= params.blocks_x || gid.y >= params.blocks_y) {\n return;\n }\n\n let bx = gid.x;\n let by = gid.y;\n let block_index = by * params.blocks_x + bx;\n\n let base = vec2<i32>(i32(bx) * 4, i32(by) * 4);\n let max_xy = vec2<i32>(i32(params.width) - 1, i32(params.height) - 1);\n\n // Load 16 RGBA pixels (8-bit integer domain) and the per-channel bbox,\n // with the covariance moments FUSED in: d = px \u2212 pixel0 (first-pixel-\n // relative, so the sums scale with the block's span; d is integer-valued\n // and \u2264255, exact in f32).\n var pixels: array<vec4<i32>, 16>;\n var lo = vec4<i32>(255);\n var hi = vec4<i32>(0);\n var gd = 0;\n var p0f = vec4<f32>(0.0);\n var sd = vec4<f32>(0.0);\n var c0v = vec4<f32>(0.0);\n var c1v = vec4<f32>(0.0);\n var c2v = vec4<f32>(0.0);\n var c3v = vec4<f32>(0.0);\n for (var i: u32 = 0u; i < 16u; i = i + 1u) {\n let lx = i32(i & 3u);\n let ly = i32(i >> 2u);\n let p = clamp(base + vec2<i32>(lx, ly), vec2<i32>(0, 0), max_xy);\n let px = to8(textureLoad(src_tex, p, 0));\n pixels[i] = px;\n lo = min(lo, px);\n hi = max(hi, px);\n gd = max(gd, max(abs(px.x - px.y), abs(px.x - px.z)));\n if (i == 0u) { p0f = vec4<f32>(px); }\n let d = vec4<f32>(px) - p0f;\n sd = sd + d;\n c0v = c0v + d.x * d;\n c1v = c1v + d.y * d;\n c2v = c2v + d.z * d;\n c3v = c3v + d.w * d;\n }\n let mean = p0f + sd * (1.0 / 16.0);\n\n // Seed endpoints from the block's principal colour axis at the exact\n // projection extents (see header), quantise, and assign indices in one\n // projection pass.\n // Mean-correct the fused moments: C = \u03A3dd\u1D40 \u2212 (\u03A3d)(\u03A3d)\u1D40/16.\n let sd16 = sd * (1.0 / 16.0);\n let r0v = c0v - sd.x * sd16;\n let r1v = c1v - sd.y * sd16;\n let r2v = c2v - sd.z * sd16;\n let r3v = c3v - sd.w * sd16;\n var seed0 = lo;\n var seed1 = hi;\n // Gray + opaque blocks: the axis is analytically (1,1,1,0)/\u221A3 with\n // extents at the luma min/max \u2014 skip iteration + extents pass entirely\n // (see bc7_fast_f16.wgsl).\n let gray = lo.w == 255 && gd == 0;\n if (gray) {\n seed0 = vec4<i32>(lo.x, lo.x, lo.x, 255);\n seed1 = vec4<i32>(hi.x, hi.x, hi.x, 255);\n } else {\n let axis = principal_axis4(r0v, r1v, r2v, r3v, vec4<f32>(hi - lo));\n if (dot(axis, axis) > 0.0) {\n // Exact projection extents along the axis. (A Rayleigh-quotient span\n // estimate was tried in place of this pass \u2014 it saves 16 dots but\n // costs 0.1\u20130.8 dB and 4\u201310\xD7 on the worst-easy-block gate: \u03C3\n // misjudges two-cluster and outlier blocks. The pass stays.)\n var t_min: f32 = 1e30;\n var t_max: f32 = -1e30;\n for (var k: u32 = 0u; k < 16u; k = k + 1u) {\n let t = dot(vec4<f32>(pixels[k]) - mean, axis);\n t_min = min(t_min, t);\n t_max = max(t_max, t);\n }\n seed0 = vec4<i32>(clamp(round(mean + t_min * axis), vec4<f32>(0.0), vec4<f32>(255.0)));\n seed1 = vec4<i32>(clamp(round(mean + t_max * axis), vec4<f32>(0.0), vec4<f32>(255.0)));\n }\n }\n\n // The 16 4-bit indices, packed LSB-first into two nibble words\n // (pixel k \u2192 bits 4k..4k+3).\n var ilo: u32 = 0u;\n var ihi: u32 = 0u;\n var ep0 = pick_ep(seed0);\n var ep1 = pick_ep(seed1);\n let dir = vec4<f32>(ep1.eight - ep0.eight);\n let dd = dot(dir, dir);\n if (dd > 0.0) {\n let e0f = vec4<f32>(ep0.eight);\n let inv = 15.0 / dd;\n for (var k: u32 = 0u; k < 8u; k = k + 1u) {\n let s = clamp(floor(dot(vec4<f32>(pixels[k]) - e0f, dir) * inv + 0.5), 0.0, 15.0);\n ilo = ilo | (u32(s) << (k * 4u));\n }\n for (var k: u32 = 8u; k < 16u; k = k + 1u) {\n let s = clamp(floor(dot(vec4<f32>(pixels[k]) - e0f, dir) * inv + 0.5), 0.0, 15.0);\n ihi = ihi | (u32(s) << ((k - 8u) * 4u));\n }\n }\n var e0_7 = ep0.seven;\n var e1_7 = ep1.seven;\n var p0 = ep0.p;\n var p1 = ep1.p;\n\n // Anchor rule \u2014 pixel 0's index MSB must be 0. Swapping endpoints reflects\n // every index (i \u2192 15\u2212i), which on packed nibbles is a bitwise NOT.\n if ((ilo & 0x8u) != 0u) {\n let t7 = e0_7; e0_7 = e1_7; e1_7 = t7;\n let tp = p0; p0 = p1; p1 = tp;\n ilo = ~ilo; ihi = ~ihi;\n }\n\n // Straight-line mode-6 packing (see layout at the top of the file).\n let e0 = vec4<u32>(e0_7);\n let e1 = vec4<u32>(e1_7);\n let w0 = 0x40u | (e0.x << 7u) | (e1.x << 14u) | (e0.y << 21u) | (e1.y << 28u);\n let w1 = (e1.y >> 4u) | (e0.z << 3u) | (e1.z << 10u) | (e0.w << 17u) | (e1.w << 24u) | (p0 << 31u);\n let w2 = p1 | ((ilo & 0x7u) << 1u) | (ilo & 0xFFFFFFF0u);\n let w3 = ihi;\n\n let out = block_index * 4u;\n dst[out + 0u] = w0;\n dst[out + 1u] = w1;\n dst[out + 2u] = w2;\n dst[out + 3u] = w3;\n}\n";
1356
+ var bc7_default = "// BC7 (BPTC) mode 6 compute shader encoder.\n//\n// One invocation per 4\xD74 block. Emits 16 bytes = 4 u32s into the storage\n// buffer at `dst[block_index * 4 .. + 3]`. This is the f32 fallback;\n// bc7_fast_f16.wgsl is the same algorithm and is preferred when the device\n// reports shader-f16.\n//\n// ALGORITHM: principal-axis seed (covariance power-iteration; bbox on\n// degenerate blocks) at the exact projection extents, quantised directly \u2014\n// no LSQ refit; with the seed on the principal axis, mode 6's 16-level\n// palette leaves the refit under 0.15 dB, unlike the 4-level BC1/ASTC\n// encoders which keep theirs \u2014 then one pass that projects each pixel onto\n// the endpoint line (the 16 palette entries are colinear, so the nearest\n// index is the rounded projection \u2014 no palette build, no 16-entry search),\n// packed on the fly into two nibble words.\n//\n// A MODE 1 (2-subset) candidate was built and evaluated (2026-07) and\n// dropped: ~+1.3 dB on multi-modal content but up to ~3\xD7 the pass cost on\n// exactly that content \u2014 see bc7_fast_f16.wgsl. The CPU reference decoder\n// keeps mode 1 support (bc7_ref.ts).\n//\n// MODE 6 LAYOUT (LSB-first, bit 0 = byte 0's bit 0)\n// bits 0..6 mode field (0b0000001 \u2014 only bit 6 is 1)\n// bits 7..13 R0 (7-bit) bits 14..20 R1 bits 21..27 G0 bits 28..34 G1\n// bits 35..41 B0 bits 42..48 B1 bits 49..55 A0 bits 56..62 A1\n// bit 63 P0 bit 64 P1\n// bits 65..67 pixel 0 index (3 bits; anchor, MSB implicit 0)\n// bits 68..71 pixel 1 index (4 bits) ... bits 124..127 pixel 15 index\n//\n// Effective 8-bit endpoint channel = (7_bit_value << 1) | p_bit.\n// Palette[i] = ((64 \u2212 W4[i]) \xD7 e0_8 + W4[i] \xD7 e1_8 + 32) >> 6, integer.\n//\n// The block is assembled with straight-line constant shifts (see the layout\n// summary in bc7_fast_f16.wgsl) \u2014 a generic write_bits() helper's dynamic\n// word indexing keeps the output array out of registers.\n\nstruct Params {\n blocks_x: u32,\n blocks_y: u32,\n width: u32,\n height: u32,\n y0: u32, // first block row of this dispatch (row-band encodes)\n};\n\n@group(0) @binding(0) var src_tex: texture_2d<f32>;\n@group(0) @binding(1) var<storage, read_write> dst: array<u32>;\n@group(0) @binding(2) var<uniform> params: Params;\n\nfn to8(v: vec4<f32>) -> vec4<i32> {\n return vec4<i32>(clamp(floor(v * 255.0 + 0.5), vec4<f32>(0.0), vec4<f32>(255.0)));\n}\n\nfn dist2(a: vec4<i32>, b: vec4<i32>) -> i32 {\n let d = a - b;\n let e = d * d;\n return e.x + e.y + e.z + e.w;\n}\n\n// Quantize an 8-bit ideal endpoint to (7-bit value, reconstructed 8-bit)\n// under a fixed p-bit, all four channels at once. q7 = round((ideal8 \u2212 p)/2).\nstruct QuantPair { seven: vec4<i32>, eight: vec4<i32> };\nfn quantize_endpoint(ideal8: vec4<i32>, p: u32) -> QuantPair {\n let q = vec4<i32>(clamp(\n floor((vec4<f32>(ideal8) - f32(p)) / 2.0 + 0.5),\n vec4<f32>(0.0), vec4<f32>(127.0),\n ));\n let eff = (q << vec4<u32>(1u)) | vec4<i32>(i32(p));\n return QuantPair(q, eff);\n}\n\n// Endpoint with its chosen p-bit, picked by minimum quantisation error.\nstruct Ep { seven: vec4<i32>, eight: vec4<i32>, p: u32 };\nfn pick_ep(ideal: vec4<i32>) -> Ep {\n let a = quantize_endpoint(ideal, 0u);\n let b = quantize_endpoint(ideal, 1u);\n if (dist2(b.eight, ideal) < dist2(a.eight, ideal)) { return Ep(b.seven, b.eight, 1u); }\n return Ep(a.seven, a.eight, 0u);\n}\n\n// Principal colour axis via power-iteration over precomputed, mean-corrected\n// covariance rows (the moments are accumulated for free in the pixel-load\n// loop), seeded with the bbox diagonal. Returns a unit axis, or vec4(0) for\n// a degenerate (constant) block. Same family as bc1.wgsl's principal_axis \u2014\n// the bbox diagonal alone is sign-blind and points across anti-correlated\n// data (normal maps, hue edges) instead of along it.\nfn principal_axis4(\n c0v: vec4<f32>,\n c1v: vec4<f32>,\n c2v: vec4<f32>,\n c3v: vec4<f32>,\n seed: vec4<f32>,\n) -> vec4<f32> {\n var v = seed;\n var len = length(v);\n if (len < 1e-9) { return vec4<f32>(0.0); }\n v = v / len;\n for (var iter: u32 = 0u; iter < 8u; iter = iter + 1u) {\n let nv = vec4<f32>(dot(c0v, v), dot(c1v, v), dot(c2v, v), dot(c3v, v));\n len = length(nv);\n if (len < 1e-12) { return vec4<f32>(0.0); }\n v = nv / len;\n }\n return v;\n}\n\n\n// ------------------------------- Entry --------------------------------- //\n\n@compute @workgroup_size(8, 8, 1)\nfn encode(@builtin(global_invocation_id) gid_raw: vec3<u32>) {\n // Row-band encodes dispatch a slice of the block grid starting at row y0.\n let gid = vec3<u32>(gid_raw.x, gid_raw.y + params.y0, gid_raw.z);\n if (gid.x >= params.blocks_x || gid.y >= params.blocks_y) {\n return;\n }\n\n let bx = gid.x;\n let by = gid.y;\n let block_index = by * params.blocks_x + bx;\n\n let base = vec2<i32>(i32(bx) * 4, i32(by) * 4);\n let max_xy = vec2<i32>(i32(params.width) - 1, i32(params.height) - 1);\n\n // Load 16 RGBA pixels (8-bit integer domain) and the per-channel bbox,\n // with the covariance moments FUSED in: d = px \u2212 pixel0 (first-pixel-\n // relative, so the sums scale with the block's span; d is integer-valued\n // and \u2264255, exact in f32).\n var pixels: array<vec4<i32>, 16>;\n var lo = vec4<i32>(255);\n var hi = vec4<i32>(0);\n var gd = 0;\n var p0f = vec4<f32>(0.0);\n var sd = vec4<f32>(0.0);\n var c0v = vec4<f32>(0.0);\n var c1v = vec4<f32>(0.0);\n var c2v = vec4<f32>(0.0);\n var c3v = vec4<f32>(0.0);\n for (var i: u32 = 0u; i < 16u; i = i + 1u) {\n let lx = i32(i & 3u);\n let ly = i32(i >> 2u);\n let p = clamp(base + vec2<i32>(lx, ly), vec2<i32>(0, 0), max_xy);\n let px = to8(textureLoad(src_tex, p, 0));\n pixels[i] = px;\n lo = min(lo, px);\n hi = max(hi, px);\n gd = max(gd, max(abs(px.x - px.y), abs(px.x - px.z)));\n if (i == 0u) { p0f = vec4<f32>(px); }\n let d = vec4<f32>(px) - p0f;\n sd = sd + d;\n c0v = c0v + d.x * d;\n c1v = c1v + d.y * d;\n c2v = c2v + d.z * d;\n c3v = c3v + d.w * d;\n }\n let mean = p0f + sd * (1.0 / 16.0);\n\n // Seed endpoints from the block's principal colour axis at the exact\n // projection extents (see header), quantise, and assign indices in one\n // projection pass.\n // Mean-correct the fused moments: C = \u03A3dd\u1D40 \u2212 (\u03A3d)(\u03A3d)\u1D40/16.\n let sd16 = sd * (1.0 / 16.0);\n let r0v = c0v - sd.x * sd16;\n let r1v = c1v - sd.y * sd16;\n let r2v = c2v - sd.z * sd16;\n let r3v = c3v - sd.w * sd16;\n var seed0 = lo;\n var seed1 = hi;\n // Gray + opaque blocks: the axis is analytically (1,1,1,0)/\u221A3 with\n // extents at the luma min/max \u2014 skip iteration + extents pass entirely\n // (see bc7_fast_f16.wgsl).\n let gray = lo.w == 255 && gd == 0;\n if (gray) {\n seed0 = vec4<i32>(lo.x, lo.x, lo.x, 255);\n seed1 = vec4<i32>(hi.x, hi.x, hi.x, 255);\n } else {\n let axis = principal_axis4(r0v, r1v, r2v, r3v, vec4<f32>(hi - lo));\n if (dot(axis, axis) > 0.0) {\n // Exact projection extents along the axis. (A Rayleigh-quotient span\n // estimate was tried in place of this pass \u2014 it saves 16 dots but\n // costs 0.1\u20130.8 dB and 4\u201310\xD7 on the worst-easy-block gate: \u03C3\n // misjudges two-cluster and outlier blocks. The pass stays.)\n var t_min: f32 = 1e30;\n var t_max: f32 = -1e30;\n for (var k: u32 = 0u; k < 16u; k = k + 1u) {\n let t = dot(vec4<f32>(pixels[k]) - mean, axis);\n t_min = min(t_min, t);\n t_max = max(t_max, t);\n }\n seed0 = vec4<i32>(clamp(round(mean + t_min * axis), vec4<f32>(0.0), vec4<f32>(255.0)));\n seed1 = vec4<i32>(clamp(round(mean + t_max * axis), vec4<f32>(0.0), vec4<f32>(255.0)));\n }\n }\n\n // The 16 4-bit indices, packed LSB-first into two nibble words\n // (pixel k \u2192 bits 4k..4k+3).\n var ilo: u32 = 0u;\n var ihi: u32 = 0u;\n var ep0 = pick_ep(seed0);\n var ep1 = pick_ep(seed1);\n let dir = vec4<f32>(ep1.eight - ep0.eight);\n let dd = dot(dir, dir);\n if (dd > 0.0) {\n let e0f = vec4<f32>(ep0.eight);\n let inv = 15.0 / dd;\n for (var k: u32 = 0u; k < 8u; k = k + 1u) {\n let s = clamp(floor(dot(vec4<f32>(pixels[k]) - e0f, dir) * inv + 0.5), 0.0, 15.0);\n ilo = ilo | (u32(s) << (k * 4u));\n }\n for (var k: u32 = 8u; k < 16u; k = k + 1u) {\n let s = clamp(floor(dot(vec4<f32>(pixels[k]) - e0f, dir) * inv + 0.5), 0.0, 15.0);\n ihi = ihi | (u32(s) << ((k - 8u) * 4u));\n }\n }\n var e0_7 = ep0.seven;\n var e1_7 = ep1.seven;\n var p0 = ep0.p;\n var p1 = ep1.p;\n\n // Anchor rule \u2014 pixel 0's index MSB must be 0. Swapping endpoints reflects\n // every index (i \u2192 15\u2212i), which on packed nibbles is a bitwise NOT.\n if ((ilo & 0x8u) != 0u) {\n let t7 = e0_7; e0_7 = e1_7; e1_7 = t7;\n let tp = p0; p0 = p1; p1 = tp;\n ilo = ~ilo; ihi = ~ihi;\n }\n\n // Straight-line mode-6 packing (see layout at the top of the file).\n let e0 = vec4<u32>(e0_7);\n let e1 = vec4<u32>(e1_7);\n let w0 = 0x40u | (e0.x << 7u) | (e1.x << 14u) | (e0.y << 21u) | (e1.y << 28u);\n let w1 = (e1.y >> 4u) | (e0.z << 3u) | (e1.z << 10u) | (e0.w << 17u) | (e1.w << 24u) | (p0 << 31u);\n let w2 = p1 | ((ilo & 0x7u) << 1u) | (ilo & 0xFFFFFFF0u);\n let w3 = ihi;\n\n let out = block_index * 4u;\n dst[out + 0u] = w0;\n dst[out + 1u] = w1;\n dst[out + 2u] = w2;\n dst[out + 3u] = w3;\n}\n";
1504
1357
 
1505
1358
  // src/bc7_fast_f16.wgsl
1506
- var bc7_fast_f16_default = "// bc7 \"fast\" encoder \u2014 f16 variant (requires the shader-f16 feature).\n// Same algorithm family as the f32 fast path in bc7.wgsl (principal-axis\n// seed at the exact projection extents \u2192 quantise \u2192 one projection-based\n// index-assignment pass), tuned for throughput:\n//\n// \u2022 All projection math in f16 ([0,1] domain). ~2\xD7 ALU throughput on\n// f16-capable GPUs. The projection direction is pre-scaled by 32:\n// a shallow block (endpoints ~1/255 apart) has dd = dot(dir,dir) \u2248 1.5e-5,\n// where 15/dd \u2248 10\u2076 overflows f16 (max 65504) to +inf and the products\n// inside the projection dot are subnormal \u2014 the indices turn to garbage\n// (visible as banding on smooth gradients). Scaling dir by 32 multiplies\n// the dots by 32 and dd by 1024; s = dot\xB7(32\xB7L/dd\u2083\u2082) is the same\n// quantity with every intermediate in f16's normal range (worst case\n// inv = 480/0.0157 \u2248 3.0e4 < 65504).\n// \u2022 TWO MODES (mode 4 OPT-IN via the enable_mode4 override constant,\n// default off and dead-coded at pipeline creation \u2014 see the constant's\n// comment for the measured cost/benefit), decided per block BEFORE\n// encoding \u2014 never encoded both:\n// mode 6 (single RGBA line, 4-bit indices) by default, mode 4 (rotation:\n// one channel split into its own scalar plane with 3-bit indices, the\n// remaining three on a 2-bit line) when the principal axis leaves a\n// large share of the block's variance unexplained \u2014 decorrelated data\n// (normal maps, channel-packed atlases) where any single 4-D line fails.\n// The decision reads the covariance already in registers (\u03BB = axis\u1D40Ca,\n// residual = trace \u2212 \u03BB) and costs no extra pass. An encode-both-and-\n// compare trial was priced at ~2\xD7 on exactly this content (see the mode\n// 1 postmortem below) \u2014 deciding first keeps it at ~1.2\xD7.\n// \u2022 The two modes SHARE the per-pixel passes (axis matvecs, projection\n// extents, the index/weight pass runs once with per-thread level count,\n// index width and packing split) so warps holding a mix of mode-4 and\n// mode-6 blocks do not execute two disjoint kernels back to back \u2014 a\n// first cut with separate per-mode passes measured 1.77\xD7 on normal maps\n// from exactly that divergence; the only mode-4-extra 16-pixel work is\n// the cheap scalar-plane pass.\n// \u2022 GRAY + opaque blocks (every texel R == G == B, A == 1) have their\n// principal axis analytically: (1,1,1,0)/\u221A3, with projection extents at\n// the luma min/max. They skip the power iteration AND the extents pass\n// (\u221234% GPU on roughness/AO/displacement content) and always take\n// mode 6 \u2014 a gray single line fits gray data exactly.\n// \u2022 NO least-squares refit, unlike the BC1/BC5/ASTC fast paths: with the\n// seed already on the principal axis at the exact projection extents,\n// mode 6's fine 16-level palette leaves the refit \u22640.05 dB on the colour\n// card, \u22640.15 dB on the normal card and +0.03 dB on the channel-packed\n// packed-materials atlas \u2014 not worth its two extra 16-pixel passes. The\n// coarse 4-level formats DO need it (dropping it there costs 0.5\u20131.3 dB).\n// \u2022 A MODE 1 (2-subset) candidate was built and evaluated (2026-07): it\n// buys ~+1.3 dB on multi-modal content but its candidate evaluation\n// costs up to ~3\xD7 the mode-6 pass on exactly that content \u2014 dropped in\n// favour of the decided (not compared) mode 4 above, which covers the\n// decorrelated-channel share of that content at a fraction of the cost.\n// The CPU reference decoder keeps mode 1 support (bc7_ref.ts).\n// \u2022 Indices are packed into two u32 words ON THE FLY during the\n// projection pass \u2014 no array<u32,16> private array. The BC7 anchor\n// reflection is then just a bitwise NOT of the packed words.\n// \u2022 The 128-bit block is assembled with straight-line constant shifts\n// instead of a generic write_bits() helper (whose dynamic word indexing\n// defeats register promotion of the output array).\n//\n// The host selects this module only when the device reports shader-f16,\n// falling back to bc7.wgsl otherwise.\n//\n// MODE 6 BIT LAYOUT (LSB-first): see bc7.wgsl. Summary:\n// w0: mode(7 bits, 0x40) R0 R1 G0 G1[3:0]\n// w1: G1[6:4] B0 B1 A0 A1 P0\n// w2: P1, pixel0 index (3 bits), pixels 1..7 (4 bits each)\n// w3: pixels 8..15 (4 bits each)\n// MODE 4 BIT LAYOUT (LSB-first): mode 0b00001, rotation @5 (channel swapped\n// with alpha), idxMode @7 (0 = colour \u2192 2-bit set, scalar \u2192 3-bit set),\n// colour endpoints 6\xD75 bits @8, alpha endpoints 2\xD76 @38, 31-bit 2-bit index\n// field @50 (pixel 0 anchored to 1 bit), 47-bit 3-bit index field @81\n// (pixel 0 anchored to 2 bits). Validated bit-exact against hardware\n// bc7-rgba-unorm sampling; decode reference in bc7_ref.ts.\nenable f16;\nstruct Params { blocks_x: u32, blocks_y: u32, width: u32, height: u32, };\n@group(0) @binding(0) var src_tex: texture_2d<f32>;\n@group(0) @binding(1) var<storage, read_write> dst: array<u32>;\n@group(0) @binding(2) var<uniform> params: Params;\nalias h = f16;\nalias h4 = vec4<f16>;\n\n// Mode-4 gate: encode mode 4 when the principal axis leaves more than\n// MODE4_THETA of the (\xD7256-scaled) total variance unexplained and the block\n// isn't near-flat. Tuned against per-content mode histograms and PSNR.\nconst MODE4_THETA: f16 = 0.2;\nconst MODE4_FLOOR: f16 = 1.0;\nconst MODE4_CONC: f16 = 0.5;\n\n// OPT-IN adaptive mode 4, folded at pipeline creation (WebGPU override\n// constant; default OFF dead-codes the whole path \u2014 measured at exact par\n// with the mode-6-only kernel). Rationale: the quality is real (+2.5\u20132.9 dB\n// on normal maps, +1.9\u20132.4 on channel-packed atlases) but any warp holding\n// one mode-4 block executes both modes' passes, and content that benefits\n// runs 1.4\u20131.5\xD7; a \u03B8 sweep showed quality and warp-poisoning scale together\n// (no per-block middle ground without subgroup ballots). So the trade is\n// the CALLER's: BC7Encoder({ adaptiveMode4: true }).\noverride enable_mode4: bool = false;\n\n// Quantise an ideal endpoint (h4 in [0,1]) to 7-bit + p-bit, choosing the\n// p-bit with the lower quantisation error. `eight` is the decoded value the\n// hardware will interpolate with, back in [0,1].\nstruct Ep { seven: vec4<u32>, eight: h4, p: u32 };\nfn pick_ep(ideal01: h4) -> Ep {\n let ideal = ideal01 * h(255.0);\n let q0 = clamp(floor(ideal * h(0.5) + h(0.5)), h4(0.0), h4(127.0)); // p=0\n let e0 = q0 * h(2.0);\n let q1 = clamp(floor((ideal - h(1.0)) * h(0.5) + h(0.5)), h4(0.0), h4(127.0)); // p=1\n let e1 = q1 * h(2.0) + h(1.0);\n let d0 = e0 - ideal; let d1 = e1 - ideal;\n if (dot(d1, d1) < dot(d0, d0)) { return Ep(vec4<u32>(q1), e1 * h(1.0 / 255.0), 1u); }\n return Ep(vec4<u32>(q0), e0 * h(1.0 / 255.0), 0u);\n}\n\n@compute @workgroup_size(8, 8, 1)\nfn encode(@builtin(global_invocation_id) gid: vec3<u32>) {\n if (gid.x >= params.blocks_x || gid.y >= params.blocks_y) { return; }\n let bi = gid.y * params.blocks_x + gid.x;\n let base = vec2<i32>(i32(gid.x) * 4, i32(gid.y) * 4);\n let mx = vec2<i32>(i32(params.width) - 1, i32(params.height) - 1);\n\n // Load pass, with the covariance moments FUSED in (no separate 16-pixel\n // pass): d = (px \u2212 pixel0)\xB716, relative to the block's first pixel so the\n // accumulators scale with the block's span \u2014 raw \u03A3v\xB7v\u1D40 moments would\n // cancel catastrophically in f16 \u2014 and pre-scaled \xD716 so shallow blocks\n // (span ~1/255 \u2192 d\xB2 \u2248 1e-3) clear the subnormal floor while full-range\n // sums stay \u22644096. C = \u03A3dd\u1D40 \u2212 (\u03A3d)(\u03A3d)\u1D40/16 is the \xD7256-scaled covariance.\n var pix: array<h4, 16>;\n var lo = h4(1.0);\n var hi = h4(0.0);\n var gd = h(0.0);\n var p0v = h4(0.0);\n var sd = h4(0.0);\n var c0v = h4(0.0);\n var c1v = h4(0.0);\n var c2v = h4(0.0);\n var c3v = h4(0.0);\n for (var i: u32 = 0u; i < 16u; i = i + 1u) {\n let p = clamp(base + vec2<i32>(i32(i & 3u), i32(i >> 2u)), vec2<i32>(0), mx);\n let px = h4(textureLoad(src_tex, p, 0));\n pix[i] = px; lo = min(lo, px); hi = max(hi, px);\n gd = max(gd, max(abs(px.x - px.y), abs(px.x - px.z)));\n if (i == 0u) { p0v = px; }\n let d = (px - p0v) * h(16.0);\n sd = sd + d;\n c0v = c0v + d.x * d;\n c1v = c1v + d.y * d;\n c2v = c2v + d.z * d;\n c3v = c3v + d.w * d;\n }\n let mean = p0v + sd * h(1.0 / 256.0);\n // Mean-correction via sd4\xB7sd4\u1D40 with sd4 = \u03A3d/4: (\u03A3d)(\u03A3d)\u1D40/16 with every\n // product \u22644096 (a direct \u03A3d\xB7\u03A3d\u1D40 could hit 65536 and overflow f16).\n let sd4 = sd * h(0.25);\n c0v = c0v - sd4.x * sd4;\n c1v = c1v - sd4.y * sd4;\n c2v = c2v - sd4.z * sd4;\n c3v = c3v - sd4.w * sd4;\n\n // Seed endpoints + per-block mode decision (see header).\n var seed_lo = lo;\n var seed_hi = hi;\n var use4 = false;\n var cmask = h4(1.0);\n var ch = 0u;\n if (lo.w == h(1.0) && gd == h(0.0)) {\n // GRAY + opaque: analytic axis (1,1,1,0)/\u221A3, extents at luma min/max,\n // always mode 6 \u2014 and a fully specialised tail: gray textures are\n // warp-uniform, and routing them through the parametric shared loop\n // below (runtime index width/split) measured +28% on displacement\n // content purely from the lost constant-shift codegen.\n var ep0g = pick_ep(h4(lo.x, lo.x, lo.x, h(1.0)));\n var ep1g = pick_ep(h4(hi.x, hi.x, hi.x, h(1.0)));\n var ilo = 0u;\n var ihi = 0u;\n let dirg = (ep1g.eight - ep0g.eight) * h(32.0);\n let ddg = dot(dirg, dirg);\n if (ddg >= h(0.008)) {\n let invg = h(480.0) / ddg;\n for (var k: u32 = 0u; k < 8u; k = k + 1u) {\n let sg = clamp(floor(dot(pix[k] - ep0g.eight, dirg) * invg + h(0.5)), h(0.0), h(15.0));\n ilo = ilo | (u32(sg) << (k * 4u));\n }\n for (var k: u32 = 8u; k < 16u; k = k + 1u) {\n let sg = clamp(floor(dot(pix[k] - ep0g.eight, dirg) * invg + h(0.5)), h(0.0), h(15.0));\n ihi = ihi | (u32(sg) << ((k - 8u) * 4u));\n }\n }\n if ((ilo & 0x8u) != 0u) {\n let t = ep0g; ep0g = ep1g; ep1g = t;\n ilo = ~ilo; ihi = ~ihi;\n }\n let e0g = ep0g.seven;\n let e1g = ep1g.seven;\n let og = bi * 4u;\n dst[og] = 0x40u | (e0g.x << 7u) | (e1g.x << 14u) | (e0g.y << 21u) | (e1g.y << 28u);\n dst[og + 1u] = (e1g.y >> 4u) | (e0g.z << 3u) | (e1g.z << 10u) | (e0g.w << 17u) | (e1g.w << 24u) | (ep0g.p << 31u);\n dst[og + 2u] = ep1g.p | ((ilo & 0x7u) << 1u) | (ilo & 0xFFFFFFF0u);\n dst[og + 3u] = ihi;\n return;\n }\n {\n var axis = hi - lo;\n var axis_ok = true;\n // 8 iterations: 4 was under-converged on noisy 4-D blocks (heavily\n // downscaled photographic/channel-packed content) \u2014 going to 8 measured\n // +0.75 dB on the normal card, +0.12 colour, +0.08 packed-materials, and\n // matches the f32 fallback's iteration count. Four extra 4-dot matvecs\n // per block are noise next to the index pass.\n for (var it: u32 = 0u; it < 8u; it = it + 1u) {\n let nv = h4(dot(c0v, axis), dot(c1v, axis), dot(c2v, axis), dot(c3v, axis));\n let m = max(max(abs(nv.x), abs(nv.y)), max(abs(nv.z), abs(nv.w)));\n if (m < h(1e-4)) { axis_ok = false; break; }\n axis = nv / m;\n }\n if (axis_ok) {\n axis = axis / length(axis);\n var axisF = axis;\n\n // Mode decision from the covariance already in registers: \u03BB is the\n // variance the mode-6 line explains, trace \u2212 \u03BB what it cannot.\n let Ca = h4(dot(c0v, axis), dot(c1v, axis), dot(c2v, axis), dot(c3v, axis));\n let lam = dot(Ca, axis);\n let diag = h4(c0v.x, c1v.y, c2v.z, c3v.w);\n let trace = diag.x + diag.y + diag.z + diag.w;\n let resid = trace - lam;\n let rc = diag - lam * axis * axis;\n var rbest = rc.x;\n if (rc.y > rbest) { ch = 1u; rbest = rc.y; }\n if (rc.z > rbest) { ch = 2u; rbest = rc.z; }\n if (rc.w > rbest) { ch = 3u; rbest = rc.w; }\n use4 = enable_mode4 && resid > MODE4_THETA * trace && trace > MODE4_FLOOR && rbest > MODE4_CONC * resid;\n if (use4) {\n // The colour plane is the remaining three channels, handled as\n // masked 4-vectors so every vec4 pass below applies unchanged.\n // Branchless mask build \u2014 a dynamic component store spills the\n // vector to scratch on some compilers.\n cmask = h4(1.0) - h4(h(f32(u32(ch == 0u))), h(f32(u32(ch == 1u))), h(f32(u32(ch == 2u))), h(f32(u32(ch == 3u))));\n var a3 = (hi - lo) * cmask;\n var ok3 = true;\n for (var it: u32 = 0u; it < 2u; it = it + 1u) {\n let nv = h4(dot(c0v, a3), dot(c1v, a3), dot(c2v, a3), dot(c3v, a3)) * cmask;\n let m = max(max(abs(nv.x), abs(nv.y)), max(abs(nv.z), abs(nv.w)));\n if (m < h(1e-4)) { ok3 = false; break; }\n a3 = nv / m;\n }\n if (ok3) {\n axisF = a3 / length(a3);\n } else {\n use4 = false;\n cmask = h4(1.0);\n }\n }\n\n // Exact projection extents along the fit axis \u2014 ONE shared pass for\n // both modes (for mode 4 axisF[ch] = 0, so the scalar plane is\n // invisible to it). (A Rayleigh-quotient span estimate was tried in\n // place of this pass \u2014 it saves 16 dots but costs 0.1\u20130.8 dB and\n // 4\u201310\xD7 on the worst-easy-block gate.)\n var t_min = h(4.0);\n var t_max = h(-4.0);\n for (var k: u32 = 0u; k < 16u; k = k + 1u) {\n let t = dot(pix[k] - mean, axisF);\n t_min = min(t_min, t);\n t_max = max(t_max, t);\n }\n seed_lo = clamp(mean + t_min * axisF, h4(0.0), h4(1.0));\n seed_hi = clamp(mean + t_max * axisF, h4(0.0), h4(1.0));\n }\n }\n\n // Endpoints, per mode. d0/d1 are the DECODED values the weight pass\n // projects against.\n var ep0: Ep;\n var ep1: Ep;\n var q0c = vec4<u32>(0u);\n var q1c = vec4<u32>(0u);\n var A0 = 0u;\n var A1 = 0u;\n var iA = 0u;\n var iB = 0u;\n var d0: h4;\n var d1: h4;\n var d0a = h(0.0);\n var sca = h(0.0);\n let chs = h4(1.0) - cmask;\n ep0 = pick_ep(seed_lo);\n ep1 = pick_ep(seed_hi);\n d0 = ep0.eight;\n d1 = ep1.eight;\n if (use4) {\n // Scalar plane (3-bit index set): 6-bit endpoints at the channel's\n // exact extremes. Its projection is FUSED into the shared weight pass\n // below \u2014 a separate 16-pixel pass here measured +46% on normal maps\n // (mixed warps paid it wholesale); fused, the marginal cost is one dot\n // per pixel under a warp-uniform predicate.\n let a0q = u32(floor(dot(lo, chs) * h(63.0) + h(0.5)));\n let a1q = u32(floor(dot(hi, chs) * h(63.0) + h(0.5)));\n A0 = a0q;\n A1 = a1q;\n let d0av = h(f32((a0q << 2u) | (a0q >> 4u))) * h(1.0 / 255.0);\n let d1av = h(f32((a1q << 2u) | (a1q >> 4u))) * h(1.0 / 255.0);\n let aspan = d1av - d0av;\n if (aspan > h(0.001)) {\n d0a = d0av;\n sca = h(7.0) / aspan;\n }\n // Colour plane: 5-bit endpoints from the masked extents seed.\n q0c = vec4<u32>(clamp(floor(seed_lo * h(31.0) + h(0.5)), h4(0.0), h4(31.0)));\n q1c = vec4<u32>(clamp(floor(seed_hi * h(31.0) + h(0.5)), h4(0.0), h4(31.0)));\n d0 = h4(vec4<f32>((q0c << vec4<u32>(3u)) | (q0c >> vec4<u32>(2u)))) * h(1.0 / 255.0) * cmask;\n d1 = h4(vec4<f32>((q1c << vec4<u32>(3u)) | (q1c >> vec4<u32>(2u)))) * h(1.0 / 255.0) * cmask;\n }\n\n // Index/weight pass: per-mode SPECIALISED loops (constant level counts\n // and shifts, so each unrolls cleanly \u2014 a single parametric loop with\n // runtime width/split measured +22% on pure mode-6 photo content).\n // Mixed warps execute both loops; the mode-4 one carries the fused\n // scalar-plane projection. For mode 4 pix[ch]\xB7dir[ch] = 0, so the\n // scalar plane never perturbs the colour projection.\n var a_lo = 0u;\n var a_hi = 0u;\n // Same \xD732 pre-scale as the extents math; distinct quantised endpoints\n // are \u22651/255 apart (dd\u2083\u2082 \u2265 0.0157), so the flat-block threshold only\n // catches truly identical ones.\n let dir = (d1 - d0) * h(32.0);\n let dd = dot(dir, dir);\n let live = dd >= h(0.008);\n if (use4) {\n if (live) {\n let inv = h(96.0) / dd;\n for (var k: u32 = 0u; k < 8u; k = k + 1u) {\n let s = clamp(floor(dot(pix[k] - d0, dir) * inv + h(0.5)), h(0.0), h(3.0));\n let sv = clamp(floor((dot(pix[k], chs) - d0a) * sca + h(0.5)), h(0.0), h(7.0));\n a_lo = a_lo | (u32(s) << (k * 2u));\n iA = iA | (u32(sv) << (k * 3u));\n }\n for (var k: u32 = 8u; k < 16u; k = k + 1u) {\n let s = clamp(floor(dot(pix[k] - d0, dir) * inv + h(0.5)), h(0.0), h(3.0));\n let sv = clamp(floor((dot(pix[k], chs) - d0a) * sca + h(0.5)), h(0.0), h(7.0));\n a_lo = a_lo | (u32(s) << (k * 2u));\n iB = iB | (u32(sv) << ((k - 8u) * 3u));\n }\n }\n } else if (live) {\n let inv = h(480.0) / dd;\n for (var k: u32 = 0u; k < 8u; k = k + 1u) {\n let s = clamp(floor(dot(pix[k] - d0, dir) * inv + h(0.5)), h(0.0), h(15.0));\n a_lo = a_lo | (u32(s) << (k * 4u));\n }\n for (var k: u32 = 8u; k < 16u; k = k + 1u) {\n let s = clamp(floor(dot(pix[k] - d0, dir) * inv + h(0.5)), h(0.0), h(15.0));\n a_hi = a_hi | (u32(s) << ((k - 8u) * 4u));\n }\n }\n\n // Anchors + packing. Mode 6 packs unconditionally (one-sided branches\n // compile better than two-sided divergence); mode-4 threads overwrite.\n let o = bi * 4u;\n {\n var ilo = a_lo;\n var ihi = a_hi;\n if ((ilo & 0x8u) != 0u) {\n let t = ep0; ep0 = ep1; ep1 = t;\n ilo = ~ilo; ihi = ~ihi;\n }\n let e0 = ep0.seven;\n let e1 = ep1.seven;\n dst[o] = 0x40u | (e0.x << 7u) | (e1.x << 14u) | (e0.y << 21u) | (e1.y << 28u);\n dst[o + 1u] = (e1.y >> 4u) | (e0.z << 3u) | (e1.z << 10u) | (e0.w << 17u) | (e1.w << 24u) | (ep0.p << 31u);\n dst[o + 2u] = ep1.p | ((ilo & 0x7u) << 1u) | (ilo & 0xFFFFFFF0u);\n dst[o + 3u] = ihi;\n }\n if (use4) {\n // 3-bit anchor: pixel 0's MSB must be 0; reflect = bitwise NOT.\n if ((iA & 4u) != 0u) {\n let tA = A0;\n A0 = A1;\n A1 = tA;\n iA = ~iA & 0xFFFFFFu;\n iB = ~iB & 0xFFFFFFu;\n }\n var c2 = a_lo;\n // 2-bit anchor: pixel 0's MSB must be 0.\n if ((c2 & 2u) != 0u) {\n let tq = q0c;\n q0c = q1c;\n q1c = tq;\n c2 = ~c2;\n }\n // Rotated-space RGB: position ch carries the original alpha.\n let R0 = select(q0c.x, q0c.w, ch == 0u);\n let G0 = select(q0c.y, q0c.w, ch == 1u);\n let B0 = select(q0c.z, q0c.w, ch == 2u);\n let R1 = select(q1c.x, q1c.w, ch == 0u);\n let G1 = select(q1c.y, q1c.w, ch == 1u);\n let B1 = select(q1c.z, q1c.w, ch == 2u);\n let rot = (ch + 1u) & 3u;\n // Index fields drop the anchors' MSBs: 31 bits (2-bit set) and 47 bits\n // (3-bit set).\n let field2 = (c2 & 1u) | ((c2 >> 2u) << 1u);\n let f_lo = (iA & 3u) | ((iA >> 3u) << 2u) | (iB << 23u);\n let f_hi = iB >> 9u;\n dst[o] = 0x10u | (rot << 5u) | (R0 << 8u) | (R1 << 13u) | (G0 << 18u) | (G1 << 23u) | (B0 << 28u);\n dst[o + 1u] = (B0 >> 4u) | (B1 << 1u) | (A0 << 6u) | (A1 << 12u) | ((field2 & 0x3FFFu) << 18u);\n dst[o + 2u] = (field2 >> 14u) | (f_lo << 17u);\n dst[o + 3u] = (f_lo >> 15u) | (f_hi << 17u);\n }\n}\n";
1359
+ var bc7_fast_f16_default = "// bc7 \"fast\" encoder \u2014 f16 variant (requires the shader-f16 feature).\n// Same algorithm family as the f32 fast path in bc7.wgsl (principal-axis\n// seed at the exact projection extents \u2192 quantise \u2192 one projection-based\n// index-assignment pass), tuned for throughput:\n//\n// \u2022 All projection math in f16 ([0,1] domain). ~2\xD7 ALU throughput on\n// f16-capable GPUs. The projection direction is pre-scaled by 32:\n// a shallow block (endpoints ~1/255 apart) has dd = dot(dir,dir) \u2248 1.5e-5,\n// where 15/dd \u2248 10\u2076 overflows f16 (max 65504) to +inf and the products\n// inside the projection dot are subnormal \u2014 the indices turn to garbage\n// (visible as banding on smooth gradients). Scaling dir by 32 multiplies\n// the dots by 32 and dd by 1024; s = dot\xB7(32\xB7L/dd\u2083\u2082) is the same\n// quantity with every intermediate in f16's normal range (worst case\n// inv = 480/0.0157 \u2248 3.0e4 < 65504).\n// \u2022 TWO MODES (mode 4 OPT-IN via the enable_mode4 override constant,\n// default off and dead-coded at pipeline creation \u2014 see the constant's\n// comment for the measured cost/benefit), decided per block BEFORE\n// encoding \u2014 never encoded both:\n// mode 6 (single RGBA line, 4-bit indices) by default, mode 4 (rotation:\n// one channel split into its own scalar plane with 3-bit indices, the\n// remaining three on a 2-bit line) when the principal axis leaves a\n// large share of the block's variance unexplained \u2014 decorrelated data\n// (normal maps, channel-packed atlases) where any single 4-D line fails.\n// The decision reads the covariance already in registers (\u03BB = axis\u1D40Ca,\n// residual = trace \u2212 \u03BB) and costs no extra pass. An encode-both-and-\n// compare trial was priced at ~2\xD7 on exactly this content (see the mode\n// 1 postmortem below) \u2014 deciding first keeps it at ~1.2\xD7.\n// \u2022 The two modes SHARE the per-pixel passes (axis matvecs, projection\n// extents, the index/weight pass runs once with per-thread level count,\n// index width and packing split) so warps holding a mix of mode-4 and\n// mode-6 blocks do not execute two disjoint kernels back to back \u2014 a\n// first cut with separate per-mode passes measured 1.77\xD7 on normal maps\n// from exactly that divergence; the only mode-4-extra 16-pixel work is\n// the cheap scalar-plane pass.\n// \u2022 GRAY + opaque blocks (every texel R == G == B, A == 1) have their\n// principal axis analytically: (1,1,1,0)/\u221A3, with projection extents at\n// the luma min/max. They skip the power iteration AND the extents pass\n// (\u221234% GPU on roughness/AO/displacement content) and always take\n// mode 6 \u2014 a gray single line fits gray data exactly.\n// \u2022 NO least-squares refit, unlike the BC1/BC5/ASTC fast paths: with the\n// seed already on the principal axis at the exact projection extents,\n// mode 6's fine 16-level palette leaves the refit \u22640.05 dB on the colour\n// card, \u22640.15 dB on the normal card and +0.03 dB on the channel-packed\n// packed-materials atlas \u2014 not worth its two extra 16-pixel passes. The\n// coarse 4-level formats DO need it (dropping it there costs 0.5\u20131.3 dB).\n// \u2022 A MODE 1 (2-subset) candidate was built and evaluated (2026-07): it\n// buys ~+1.3 dB on multi-modal content but its candidate evaluation\n// costs up to ~3\xD7 the mode-6 pass on exactly that content \u2014 dropped in\n// favour of the decided (not compared) mode 4 above, which covers the\n// decorrelated-channel share of that content at a fraction of the cost.\n// The CPU reference decoder keeps mode 1 support (bc7_ref.ts).\n// \u2022 Indices are packed into two u32 words ON THE FLY during the\n// projection pass \u2014 no array<u32,16> private array. The BC7 anchor\n// reflection is then just a bitwise NOT of the packed words.\n// \u2022 The 128-bit block is assembled with straight-line constant shifts\n// instead of a generic write_bits() helper (whose dynamic word indexing\n// defeats register promotion of the output array).\n//\n// The host selects this module only when the device reports shader-f16,\n// falling back to bc7.wgsl otherwise.\n//\n// MODE 6 BIT LAYOUT (LSB-first): see bc7.wgsl. Summary:\n// w0: mode(7 bits, 0x40) R0 R1 G0 G1[3:0]\n// w1: G1[6:4] B0 B1 A0 A1 P0\n// w2: P1, pixel0 index (3 bits), pixels 1..7 (4 bits each)\n// w3: pixels 8..15 (4 bits each)\n// MODE 4 BIT LAYOUT (LSB-first): mode 0b00001, rotation @5 (channel swapped\n// with alpha), idxMode @7 (0 = colour \u2192 2-bit set, scalar \u2192 3-bit set),\n// colour endpoints 6\xD75 bits @8, alpha endpoints 2\xD76 @38, 31-bit 2-bit index\n// field @50 (pixel 0 anchored to 1 bit), 47-bit 3-bit index field @81\n// (pixel 0 anchored to 2 bits). Validated bit-exact against hardware\n// bc7-rgba-unorm sampling; decode reference in bc7_ref.ts.\nenable f16;\nstruct Params { blocks_x: u32, blocks_y: u32, width: u32, height: u32, y0: u32, };\n@group(0) @binding(0) var src_tex: texture_2d<f32>;\n@group(0) @binding(1) var<storage, read_write> dst: array<u32>;\n@group(0) @binding(2) var<uniform> params: Params;\nalias h = f16;\nalias h4 = vec4<f16>;\n\n// Mode-4 gate: encode mode 4 when the principal axis leaves more than\n// MODE4_THETA of the (\xD7256-scaled) total variance unexplained and the block\n// isn't near-flat. Tuned against per-content mode histograms and PSNR.\nconst MODE4_THETA: f16 = 0.2;\nconst MODE4_FLOOR: f16 = 1.0;\nconst MODE4_CONC: f16 = 0.5;\n\n// OPT-IN adaptive mode 4, folded at pipeline creation (WebGPU override\n// constant; default OFF dead-codes the whole path \u2014 measured at exact par\n// with the mode-6-only kernel). Rationale: the quality is real (+2.5\u20132.9 dB\n// on normal maps, +1.9\u20132.4 on channel-packed atlases) but any warp holding\n// one mode-4 block executes both modes' passes, and content that benefits\n// runs 1.4\u20131.5\xD7; a \u03B8 sweep showed quality and warp-poisoning scale together\n// (no per-block middle ground without subgroup ballots). So the trade is\n// the CALLER's: BC7Encoder({ adaptiveMode4: true }).\noverride enable_mode4: bool = false;\n\n// Quantise an ideal endpoint (h4 in [0,1]) to 7-bit + p-bit, choosing the\n// p-bit with the lower quantisation error. `eight` is the decoded value the\n// hardware will interpolate with, back in [0,1].\nstruct Ep { seven: vec4<u32>, eight: h4, p: u32 };\nfn pick_ep(ideal01: h4) -> Ep {\n let ideal = ideal01 * h(255.0);\n let q0 = clamp(floor(ideal * h(0.5) + h(0.5)), h4(0.0), h4(127.0)); // p=0\n let e0 = q0 * h(2.0);\n let q1 = clamp(floor((ideal - h(1.0)) * h(0.5) + h(0.5)), h4(0.0), h4(127.0)); // p=1\n let e1 = q1 * h(2.0) + h(1.0);\n let d0 = e0 - ideal; let d1 = e1 - ideal;\n if (dot(d1, d1) < dot(d0, d0)) { return Ep(vec4<u32>(q1), e1 * h(1.0 / 255.0), 1u); }\n return Ep(vec4<u32>(q0), e0 * h(1.0 / 255.0), 0u);\n}\n\n@compute @workgroup_size(8, 8, 1)\nfn encode(@builtin(global_invocation_id) gid_raw: vec3<u32>) {\n // Row-band encodes dispatch a slice of the block grid starting at row y0.\n let gid = vec3<u32>(gid_raw.x, gid_raw.y + params.y0, gid_raw.z);\n if (gid.x >= params.blocks_x || gid.y >= params.blocks_y) { return; }\n let bi = gid.y * params.blocks_x + gid.x;\n let base = vec2<i32>(i32(gid.x) * 4, i32(gid.y) * 4);\n let mx = vec2<i32>(i32(params.width) - 1, i32(params.height) - 1);\n\n // Load pass, with the covariance moments FUSED in (no separate 16-pixel\n // pass): d = (px \u2212 pixel0)\xB716, relative to the block's first pixel so the\n // accumulators scale with the block's span \u2014 raw \u03A3v\xB7v\u1D40 moments would\n // cancel catastrophically in f16 \u2014 and pre-scaled \xD716 so shallow blocks\n // (span ~1/255 \u2192 d\xB2 \u2248 1e-3) clear the subnormal floor while full-range\n // sums stay \u22644096. C = \u03A3dd\u1D40 \u2212 (\u03A3d)(\u03A3d)\u1D40/16 is the \xD7256-scaled covariance.\n var pix: array<h4, 16>;\n var lo = h4(1.0);\n var hi = h4(0.0);\n var gd = h(0.0);\n var p0v = h4(0.0);\n var sd = h4(0.0);\n var c0v = h4(0.0);\n var c1v = h4(0.0);\n var c2v = h4(0.0);\n var c3v = h4(0.0);\n for (var i: u32 = 0u; i < 16u; i = i + 1u) {\n let p = clamp(base + vec2<i32>(i32(i & 3u), i32(i >> 2u)), vec2<i32>(0), mx);\n let px = h4(textureLoad(src_tex, p, 0));\n pix[i] = px; lo = min(lo, px); hi = max(hi, px);\n gd = max(gd, max(abs(px.x - px.y), abs(px.x - px.z)));\n if (i == 0u) { p0v = px; }\n let d = (px - p0v) * h(16.0);\n sd = sd + d;\n c0v = c0v + d.x * d;\n c1v = c1v + d.y * d;\n c2v = c2v + d.z * d;\n c3v = c3v + d.w * d;\n }\n let mean = p0v + sd * h(1.0 / 256.0);\n // Mean-correction via sd4\xB7sd4\u1D40 with sd4 = \u03A3d/4: (\u03A3d)(\u03A3d)\u1D40/16 with every\n // product \u22644096 (a direct \u03A3d\xB7\u03A3d\u1D40 could hit 65536 and overflow f16).\n let sd4 = sd * h(0.25);\n c0v = c0v - sd4.x * sd4;\n c1v = c1v - sd4.y * sd4;\n c2v = c2v - sd4.z * sd4;\n c3v = c3v - sd4.w * sd4;\n\n // Seed endpoints + per-block mode decision (see header).\n var seed_lo = lo;\n var seed_hi = hi;\n var use4 = false;\n var cmask = h4(1.0);\n var ch = 0u;\n if (lo.w == h(1.0) && gd == h(0.0)) {\n // GRAY + opaque: analytic axis (1,1,1,0)/\u221A3, extents at luma min/max,\n // always mode 6 \u2014 and a fully specialised tail: gray textures are\n // warp-uniform, and routing them through the parametric shared loop\n // below (runtime index width/split) measured +28% on displacement\n // content purely from the lost constant-shift codegen.\n var ep0g = pick_ep(h4(lo.x, lo.x, lo.x, h(1.0)));\n var ep1g = pick_ep(h4(hi.x, hi.x, hi.x, h(1.0)));\n var ilo = 0u;\n var ihi = 0u;\n let dirg = (ep1g.eight - ep0g.eight) * h(32.0);\n let ddg = dot(dirg, dirg);\n if (ddg >= h(0.008)) {\n let invg = h(480.0) / ddg;\n for (var k: u32 = 0u; k < 8u; k = k + 1u) {\n let sg = clamp(floor(dot(pix[k] - ep0g.eight, dirg) * invg + h(0.5)), h(0.0), h(15.0));\n ilo = ilo | (u32(sg) << (k * 4u));\n }\n for (var k: u32 = 8u; k < 16u; k = k + 1u) {\n let sg = clamp(floor(dot(pix[k] - ep0g.eight, dirg) * invg + h(0.5)), h(0.0), h(15.0));\n ihi = ihi | (u32(sg) << ((k - 8u) * 4u));\n }\n }\n if ((ilo & 0x8u) != 0u) {\n let t = ep0g; ep0g = ep1g; ep1g = t;\n ilo = ~ilo; ihi = ~ihi;\n }\n let e0g = ep0g.seven;\n let e1g = ep1g.seven;\n let og = bi * 4u;\n dst[og] = 0x40u | (e0g.x << 7u) | (e1g.x << 14u) | (e0g.y << 21u) | (e1g.y << 28u);\n dst[og + 1u] = (e1g.y >> 4u) | (e0g.z << 3u) | (e1g.z << 10u) | (e0g.w << 17u) | (e1g.w << 24u) | (ep0g.p << 31u);\n dst[og + 2u] = ep1g.p | ((ilo & 0x7u) << 1u) | (ilo & 0xFFFFFFF0u);\n dst[og + 3u] = ihi;\n return;\n }\n {\n var axis = hi - lo;\n var axis_ok = true;\n // 8 iterations: 4 was under-converged on noisy 4-D blocks (heavily\n // downscaled photographic/channel-packed content) \u2014 going to 8 measured\n // +0.75 dB on the normal card, +0.12 colour, +0.08 packed-materials, and\n // matches the f32 fallback's iteration count. Four extra 4-dot matvecs\n // per block are noise next to the index pass.\n for (var it: u32 = 0u; it < 8u; it = it + 1u) {\n let nv = h4(dot(c0v, axis), dot(c1v, axis), dot(c2v, axis), dot(c3v, axis));\n let m = max(max(abs(nv.x), abs(nv.y)), max(abs(nv.z), abs(nv.w)));\n if (m < h(1e-4)) { axis_ok = false; break; }\n axis = nv / m;\n }\n if (axis_ok) {\n axis = axis / length(axis);\n var axisF = axis;\n\n // Mode decision from the covariance already in registers: \u03BB is the\n // variance the mode-6 line explains, trace \u2212 \u03BB what it cannot.\n let Ca = h4(dot(c0v, axis), dot(c1v, axis), dot(c2v, axis), dot(c3v, axis));\n let lam = dot(Ca, axis);\n let diag = h4(c0v.x, c1v.y, c2v.z, c3v.w);\n let trace = diag.x + diag.y + diag.z + diag.w;\n let resid = trace - lam;\n let rc = diag - lam * axis * axis;\n var rbest = rc.x;\n if (rc.y > rbest) { ch = 1u; rbest = rc.y; }\n if (rc.z > rbest) { ch = 2u; rbest = rc.z; }\n if (rc.w > rbest) { ch = 3u; rbest = rc.w; }\n use4 = enable_mode4 && resid > MODE4_THETA * trace && trace > MODE4_FLOOR && rbest > MODE4_CONC * resid;\n if (use4) {\n // The colour plane is the remaining three channels, handled as\n // masked 4-vectors so every vec4 pass below applies unchanged.\n // Branchless mask build \u2014 a dynamic component store spills the\n // vector to scratch on some compilers.\n cmask = h4(1.0) - h4(h(f32(u32(ch == 0u))), h(f32(u32(ch == 1u))), h(f32(u32(ch == 2u))), h(f32(u32(ch == 3u))));\n var a3 = (hi - lo) * cmask;\n var ok3 = true;\n for (var it: u32 = 0u; it < 2u; it = it + 1u) {\n let nv = h4(dot(c0v, a3), dot(c1v, a3), dot(c2v, a3), dot(c3v, a3)) * cmask;\n let m = max(max(abs(nv.x), abs(nv.y)), max(abs(nv.z), abs(nv.w)));\n if (m < h(1e-4)) { ok3 = false; break; }\n a3 = nv / m;\n }\n if (ok3) {\n axisF = a3 / length(a3);\n } else {\n use4 = false;\n cmask = h4(1.0);\n }\n }\n\n // Exact projection extents along the fit axis \u2014 ONE shared pass for\n // both modes (for mode 4 axisF[ch] = 0, so the scalar plane is\n // invisible to it). (A Rayleigh-quotient span estimate was tried in\n // place of this pass \u2014 it saves 16 dots but costs 0.1\u20130.8 dB and\n // 4\u201310\xD7 on the worst-easy-block gate.)\n var t_min = h(4.0);\n var t_max = h(-4.0);\n for (var k: u32 = 0u; k < 16u; k = k + 1u) {\n let t = dot(pix[k] - mean, axisF);\n t_min = min(t_min, t);\n t_max = max(t_max, t);\n }\n seed_lo = clamp(mean + t_min * axisF, h4(0.0), h4(1.0));\n seed_hi = clamp(mean + t_max * axisF, h4(0.0), h4(1.0));\n }\n }\n\n // Endpoints, per mode. d0/d1 are the DECODED values the weight pass\n // projects against.\n var ep0: Ep;\n var ep1: Ep;\n var q0c = vec4<u32>(0u);\n var q1c = vec4<u32>(0u);\n var A0 = 0u;\n var A1 = 0u;\n var iA = 0u;\n var iB = 0u;\n var d0: h4;\n var d1: h4;\n var d0a = h(0.0);\n var sca = h(0.0);\n let chs = h4(1.0) - cmask;\n ep0 = pick_ep(seed_lo);\n ep1 = pick_ep(seed_hi);\n d0 = ep0.eight;\n d1 = ep1.eight;\n if (use4) {\n // Scalar plane (3-bit index set): 6-bit endpoints at the channel's\n // exact extremes. Its projection is FUSED into the shared weight pass\n // below \u2014 a separate 16-pixel pass here measured +46% on normal maps\n // (mixed warps paid it wholesale); fused, the marginal cost is one dot\n // per pixel under a warp-uniform predicate.\n let a0q = u32(floor(dot(lo, chs) * h(63.0) + h(0.5)));\n let a1q = u32(floor(dot(hi, chs) * h(63.0) + h(0.5)));\n A0 = a0q;\n A1 = a1q;\n let d0av = h(f32((a0q << 2u) | (a0q >> 4u))) * h(1.0 / 255.0);\n let d1av = h(f32((a1q << 2u) | (a1q >> 4u))) * h(1.0 / 255.0);\n let aspan = d1av - d0av;\n if (aspan > h(0.001)) {\n d0a = d0av;\n sca = h(7.0) / aspan;\n }\n // Colour plane: 5-bit endpoints from the masked extents seed.\n q0c = vec4<u32>(clamp(floor(seed_lo * h(31.0) + h(0.5)), h4(0.0), h4(31.0)));\n q1c = vec4<u32>(clamp(floor(seed_hi * h(31.0) + h(0.5)), h4(0.0), h4(31.0)));\n d0 = h4(vec4<f32>((q0c << vec4<u32>(3u)) | (q0c >> vec4<u32>(2u)))) * h(1.0 / 255.0) * cmask;\n d1 = h4(vec4<f32>((q1c << vec4<u32>(3u)) | (q1c >> vec4<u32>(2u)))) * h(1.0 / 255.0) * cmask;\n }\n\n // Index/weight pass: per-mode SPECIALISED loops (constant level counts\n // and shifts, so each unrolls cleanly \u2014 a single parametric loop with\n // runtime width/split measured +22% on pure mode-6 photo content).\n // Mixed warps execute both loops; the mode-4 one carries the fused\n // scalar-plane projection. For mode 4 pix[ch]\xB7dir[ch] = 0, so the\n // scalar plane never perturbs the colour projection.\n var a_lo = 0u;\n var a_hi = 0u;\n // Same \xD732 pre-scale as the extents math; distinct quantised endpoints\n // are \u22651/255 apart (dd\u2083\u2082 \u2265 0.0157), so the flat-block threshold only\n // catches truly identical ones.\n let dir = (d1 - d0) * h(32.0);\n let dd = dot(dir, dir);\n let live = dd >= h(0.008);\n if (use4) {\n if (live) {\n let inv = h(96.0) / dd;\n for (var k: u32 = 0u; k < 8u; k = k + 1u) {\n let s = clamp(floor(dot(pix[k] - d0, dir) * inv + h(0.5)), h(0.0), h(3.0));\n let sv = clamp(floor((dot(pix[k], chs) - d0a) * sca + h(0.5)), h(0.0), h(7.0));\n a_lo = a_lo | (u32(s) << (k * 2u));\n iA = iA | (u32(sv) << (k * 3u));\n }\n for (var k: u32 = 8u; k < 16u; k = k + 1u) {\n let s = clamp(floor(dot(pix[k] - d0, dir) * inv + h(0.5)), h(0.0), h(3.0));\n let sv = clamp(floor((dot(pix[k], chs) - d0a) * sca + h(0.5)), h(0.0), h(7.0));\n a_lo = a_lo | (u32(s) << (k * 2u));\n iB = iB | (u32(sv) << ((k - 8u) * 3u));\n }\n }\n } else if (live) {\n let inv = h(480.0) / dd;\n for (var k: u32 = 0u; k < 8u; k = k + 1u) {\n let s = clamp(floor(dot(pix[k] - d0, dir) * inv + h(0.5)), h(0.0), h(15.0));\n a_lo = a_lo | (u32(s) << (k * 4u));\n }\n for (var k: u32 = 8u; k < 16u; k = k + 1u) {\n let s = clamp(floor(dot(pix[k] - d0, dir) * inv + h(0.5)), h(0.0), h(15.0));\n a_hi = a_hi | (u32(s) << ((k - 8u) * 4u));\n }\n }\n\n // Anchors + packing. Mode 6 packs unconditionally (one-sided branches\n // compile better than two-sided divergence); mode-4 threads overwrite.\n let o = bi * 4u;\n {\n var ilo = a_lo;\n var ihi = a_hi;\n if ((ilo & 0x8u) != 0u) {\n let t = ep0; ep0 = ep1; ep1 = t;\n ilo = ~ilo; ihi = ~ihi;\n }\n let e0 = ep0.seven;\n let e1 = ep1.seven;\n dst[o] = 0x40u | (e0.x << 7u) | (e1.x << 14u) | (e0.y << 21u) | (e1.y << 28u);\n dst[o + 1u] = (e1.y >> 4u) | (e0.z << 3u) | (e1.z << 10u) | (e0.w << 17u) | (e1.w << 24u) | (ep0.p << 31u);\n dst[o + 2u] = ep1.p | ((ilo & 0x7u) << 1u) | (ilo & 0xFFFFFFF0u);\n dst[o + 3u] = ihi;\n }\n if (use4) {\n // 3-bit anchor: pixel 0's MSB must be 0; reflect = bitwise NOT.\n if ((iA & 4u) != 0u) {\n let tA = A0;\n A0 = A1;\n A1 = tA;\n iA = ~iA & 0xFFFFFFu;\n iB = ~iB & 0xFFFFFFu;\n }\n var c2 = a_lo;\n // 2-bit anchor: pixel 0's MSB must be 0.\n if ((c2 & 2u) != 0u) {\n let tq = q0c;\n q0c = q1c;\n q1c = tq;\n c2 = ~c2;\n }\n // Rotated-space RGB: position ch carries the original alpha.\n let R0 = select(q0c.x, q0c.w, ch == 0u);\n let G0 = select(q0c.y, q0c.w, ch == 1u);\n let B0 = select(q0c.z, q0c.w, ch == 2u);\n let R1 = select(q1c.x, q1c.w, ch == 0u);\n let G1 = select(q1c.y, q1c.w, ch == 1u);\n let B1 = select(q1c.z, q1c.w, ch == 2u);\n let rot = (ch + 1u) & 3u;\n // Index fields drop the anchors' MSBs: 31 bits (2-bit set) and 47 bits\n // (3-bit set).\n let field2 = (c2 & 1u) | ((c2 >> 2u) << 1u);\n let f_lo = (iA & 3u) | ((iA >> 3u) << 2u) | (iB << 23u);\n let f_hi = iB >> 9u;\n dst[o] = 0x10u | (rot << 5u) | (R0 << 8u) | (R1 << 13u) | (G0 << 18u) | (G1 << 23u) | (B0 << 28u);\n dst[o + 1u] = (B0 >> 4u) | (B1 << 1u) | (A0 << 6u) | (A1 << 12u) | ((field2 & 0x3FFFu) << 18u);\n dst[o + 2u] = (field2 >> 14u) | (f_lo << 17u);\n dst[o + 3u] = (f_lo >> 15u) | (f_hi << 17u);\n }\n}\n";
1507
1360
 
1508
1361
  // src/BC7Encoder.ts
1509
1362
  var BC7Encoder = class extends Encoder {
@@ -1540,80 +1393,93 @@ var BC7Encoder = class extends Encoder {
1540
1393
  };
1541
1394
 
1542
1395
  // src/astc4x4.wgsl
1543
- var astc4x4_default = "// ASTC 4\xD74 LDR compute shader encoder.\n//\n// One invocation per 4\xD74 block. Emits 16 bytes = 4 u32s into the storage\n// buffer at `dst[block_index * 4 .. + 3]`. This is the f32 fallback;\n// astc4x4_fast_f16.wgsl is the same algorithm and is preferred when the\n// device reports shader-f16.\n//\n// ALGORITHM: per-block class selection, then a single-line fit:\n// gray + opaque \u2192 CEM 0 (luminance), 5-bit weights, block mode 0x253 \u2014\n// scalar path: exact min/max endpoints, 32-level weight\n// assignment, no covariance / iteration / refit needed\n// opaque \u2192 CEM 8 (RGB), 3-bit weights, block mode 0x053\n// translucent \u2192 CEM 12 (RGBA), 2-bit weights, block mode 0x042\n// Colour paths: principal-axis seed (covariance power-iteration; bbox on\n// degenerate blocks) at the exact projection extents \u2192 one fused pass that\n// projects each pixel onto the endpoint line (the palette entries are\n// colinear, so the nearest is the rounded projection \u2014 no per-entry search)\n// while accumulating the least-squares refit sums, then a reprojection\n// against the quantised refit endpoints with the weights packed on the fly.\n// The endpoint ordering rule is applied before the weight pass, so no\n// reflection is needed.\n//\n// RESTRICTED SUBSET + BLOCK LAYOUT: see astc4x4_ref.ts (single partition,\n// no dual-plane, CEM 0/8/12, 8-bit endpoints, plain-bit weight ISE; block\n// mode derivations and the weight-stream bit order are documented there).\n//\n// WEIGHT PLACEMENT: stream bit q (bit j of weight k, q = nBits\xB7k + j) lives\n// at block bit 127 \u2212 q, so a stream word assembled LSB-first maps onto a\n// block word with a single reverseBits().\n//\n// ENDPOINT ORDERING: CEM 8/12 decoders branch into blue contraction when\n// sum(e0.rgb) > sum(e1.rgb); the encoder swaps endpoints up front (weights\n// are assigned after the swap, so no reflection pass). CEM 0 has no rule\n// (L0 \u2264 L1 by construction from min/max).\n\nstruct Params {\n blocks_x: u32,\n blocks_y: u32,\n width: u32,\n height: u32,\n};\n\n@group(0) @binding(0) var src_tex: texture_2d<f32>;\n@group(0) @binding(1) var<storage, read_write> dst: array<u32>;\n@group(0) @binding(2) var<uniform> params: Params;\n\nfn to8(v: vec4<f32>) -> vec4<i32> {\n return vec4<i32>(clamp(floor(v * 255.0 + 0.5), vec4<f32>(0.0), vec4<f32>(255.0)));\n}\n\n// One pass over the block: project every pixel onto the e0\u2192e1 line\n// (lmax + 1 colinear levels, so the nearest entry is the rounded\n// projection) and accumulate the least-squares normal-equation sums;\n// solve for the refit endpoints. Weights are not produced here \u2014 the\n// caller reprojects against the quantised refit endpoints anyway.\nstruct Fit { e0: vec4<i32>, e1: vec4<i32>, valid: bool, wstream: u32 };\nfn proj_fit(pixels: ptr<function, array<vec4<i32>, 16>>, e0: vec4<i32>, e1: vec4<i32>) -> Fit {\n var out: Fit;\n out.valid = false;\n out.wstream = 0u;\n let dir = vec4<f32>(e1 - e0);\n let dd = dot(dir, dir);\n if (dd == 0.0) { return out; }\n let e0f = vec4<f32>(e0);\n let inv = 3.0 / dd;\n var sAA: f32 = 0.0; var sBB: f32 = 0.0; var sAB: f32 = 0.0;\n var sAV: vec4<f32> = vec4<f32>(0.0); var sBV: vec4<f32> = vec4<f32>(0.0);\n var s_min = 3.0; var s_max = 0.0;\n for (var k: u32 = 0u; k < 16u; k = k + 1u) {\n let v = vec4<f32>((*pixels)[k]);\n let s = clamp(floor(dot(v - e0f, dir) * inv + 0.5), 0.0, 3.0);\n out.wstream = out.wstream | (u32(s) << (2u * k));\n s_min = min(s_min, s); s_max = max(s_max, s);\n let b = s * (1.0 / 3.0); let a = 1.0 - b;\n sAA = sAA + a * a; sBB = sBB + b * b; sAB = sAB + a * b;\n sAV = sAV + a * v; sBV = sBV + b * v;\n }\n // Rank-1 guard: if every pixel projects to ONE level the system is\n // singular \u2014 det and the numerators are pure float rounding noise and the\n // solve returns garbage endpoints. With \u22652 levels det \u2265 15\xB7(1/3)\xB2 \u2248 1.67.\n if (s_min == s_max) { return out; }\n let det = sAA * sBB - sAB * sAB;\n if (abs(det) < 1e-3) { return out; }\n out.e0 = vec4<i32>(clamp(round((sBB * sAV - sAB * sBV) / det), vec4<f32>(0.0), vec4<f32>(255.0)));\n out.e1 = vec4<i32>(clamp(round((sAA * sBV - sAB * sAV) / det), vec4<f32>(0.0), vec4<f32>(255.0)));\n out.valid = true;\n return out;\n}\n\n// Principal colour axis via covariance power-iteration (RGBA, 8-bit integer\n// pixel domain), seeded with the bbox diagonal. Returns a unit axis, or\n// vec4(0) for a degenerate (constant) block. Used to seed the LSQ fit \u2014 the\n// bbox diagonal is sign-blind and points across anti-correlated data (normal\n// maps, hue edges) instead of along it.\nfn principal_axis4(\n pixels: ptr<function, array<vec4<i32>, 16>>,\n mean: vec4<f32>,\n seed: vec4<f32>,\n iters: u32,\n) -> vec4<f32> {\n var c0v = vec4<f32>(0.0);\n var c1v = vec4<f32>(0.0);\n var c2v = vec4<f32>(0.0);\n var c3v = vec4<f32>(0.0);\n for (var k: u32 = 0u; k < 16u; k = k + 1u) {\n let d = vec4<f32>((*pixels)[k]) - mean;\n c0v = c0v + d.x * d;\n c1v = c1v + d.y * d;\n c2v = c2v + d.z * d;\n c3v = c3v + d.w * d;\n }\n var v = seed;\n var len = length(v);\n if (len < 1e-9) { return vec4<f32>(0.0); }\n v = v / len;\n for (var iter: u32 = 0u; iter < iters; iter = iter + 1u) {\n let nv = vec4<f32>(dot(c0v, v), dot(c1v, v), dot(c2v, v), dot(c3v, v));\n len = length(nv);\n if (len < 1e-12) { return vec4<f32>(0.0); }\n v = nv / len;\n }\n return v;\n}\n\n// ------------------------------- Entry ---------------------------------- //\n\n@compute @workgroup_size(8, 8, 1)\nfn encode(@builtin(global_invocation_id) gid: vec3<u32>) {\n if (gid.x >= params.blocks_x || gid.y >= params.blocks_y) {\n return;\n }\n\n let bx = gid.x;\n let by = gid.y;\n let block_index = by * params.blocks_x + bx;\n\n let base = vec2<i32>(i32(bx) * 4, i32(by) * 4);\n let max_xy = vec2<i32>(i32(params.width) - 1, i32(params.height) - 1);\n\n var pixels: array<vec4<i32>, 16>;\n var lo = vec4<i32>(255);\n var hi = vec4<i32>(0);\n var isum = vec4<i32>(0);\n var gd = 0; // max |R\u2212G|, |R\u2212B| over the block; 0 \u21D4 exactly grayscale\n for (var i: u32 = 0u; i < 16u; i = i + 1u) {\n let p = clamp(base + vec2<i32>(i32(i & 3u), i32(i >> 2u)), vec2<i32>(0, 0), max_xy);\n let px = to8(textureLoad(src_tex, p, 0));\n pixels[i] = px;\n lo = min(lo, px);\n hi = max(hi, px);\n isum = isum + px;\n gd = max(gd, max(abs(px.x - px.y), abs(px.x - px.z)));\n }\n let opaque = lo.w == 255;\n\n var w0: u32; var w1: u32; var w2: u32; var w3: u32;\n\n if (opaque && gd == 0) {\n // ---------------- Luminance path: CEM 0, 5-bit weights ----------------\n // Endpoints at the exact extremes; 32 palette levels make an LSQ refit\n // unnecessary.\n let L0 = u32(lo.x);\n let L1 = u32(hi.x);\n var s0 = 0u; var s1 = 0u; var s2 = 0u;\n if (L1 > L0) {\n let sc = 64.0 / f32(hi.x - lo.x);\n // Exact nearest entry of the QUANT_32 grid: unq = 2w for w \u2264 15,\n // 2w + 2 for w \u2265 16 (4-wide gap at the middle, so uniform rounding\n // is wrong there). Best candidate of each half, keep the closer.\n for (var k: u32 = 0u; k < 16u; k = k + 1u) {\n let u = clamp(f32(pixels[k].x - lo.x) * sc, 0.0, 64.0);\n let wlo = clamp(floor(u * 0.5 + 0.5), 0.0, 15.0);\n let whi = clamp(floor((u - 2.0) * 0.5 + 0.5), 16.0, 31.0);\n let pick = abs(u - wlo * 2.0) <= abs(u - (whi * 2.0 + 2.0));\n let w = u32(select(whi, wlo, pick));\n // Stream bit q = 5k + j; straddles handled with constant shifts.\n let off = 5u * k;\n if (off < 28u) { s0 = s0 | (w << off); }\n else if (off == 30u) { s0 = s0 | (w << 30u); s1 = s1 | (w >> 2u); }\n else if (off < 60u) { s1 = s1 | (w << (off - 32u)); }\n else if (off == 60u) { s1 = s1 | (w << 28u); s2 = s2 | (w >> 4u); }\n else { s2 = s2 | (w << (off - 64u)); }\n }\n }\n // Mode 0x253, partitions\u22121 = 0, CEM 0, L0 @17, L1 @25 (top bit spills\n // into w1 bit 0); stream words map onto block words via reverseBits.\n w0 = 0x253u | (L0 << 17u) | (L1 << 25u);\n w1 = (L1 >> 7u) | reverseBits(s2);\n w2 = reverseBits(s1);\n w3 = reverseBits(s0);\n } else {\n // ------------- Colour paths: shared PCA seed ---------------------------\n let mean = vec4<f32>(isum) * (1.0 / 16.0);\n\n // Fused LSQ fit seeded from the block's principal colour axis at the\n // exact projection extents, quantised refit endpoints, ordering applied\n // BEFORE the weight pass so no reflection is needed.\n // The refit is clamped to the block bbox: on multi-cluster blocks the\n // unconstrained solve extrapolates far outside the block's colours and\n // the per-channel [0,255] clamp then bends the hue \u2014 fringe pixels\n // decode to colours that exist nowhere in the block. Constraining to\n // the bbox also measures better in plain SSE (+1.8 dB on the colour\n // test card).\n var seed0 = lo;\n var seed1 = hi;\n // 8 iterations for opaque blocks (the axis is the endpoint quality\n // there), 4 for translucent ones whose LSQ refit absorbs residual\n // axis error (see astc4x4_fast_f16.wgsl).\n let axis = principal_axis4(&pixels, mean, vec4<f32>(hi - lo), select(4u, 8u, opaque));\n if (dot(axis, axis) > 0.0) {\n var t_min: f32 = 1e30;\n var t_max: f32 = -1e30;\n for (var k: u32 = 0u; k < 16u; k = k + 1u) {\n let t = dot(vec4<f32>(pixels[k]) - mean, axis);\n t_min = min(t_min, t);\n t_max = max(t_max, t);\n }\n seed0 = vec4<i32>(clamp(round(mean + t_min * axis), vec4<f32>(0.0), vec4<f32>(255.0)));\n seed1 = vec4<i32>(clamp(round(mean + t_max * axis), vec4<f32>(0.0), vec4<f32>(255.0)));\n }\n // Opaque blocks (CEM 8, 8-level weights) ship the quantised PCA\n // extents directly; only the translucent CEM 12 path refits its coarse\n // 4-level grid (see astc4x4_fast_f16.wgsl for the measured trade).\n var e0 = lo;\n var e1 = hi;\n var fitStream = 0u;\n var haveFitWeights = false;\n if (opaque) {\n // Bbox-clamped like the fit output (see astc4x4_fast_f16.wgsl).\n e0 = clamp(seed0, lo, hi);\n e1 = clamp(seed1, lo, hi);\n } else {\n let r = proj_fit(&pixels, seed0, seed1);\n if (r.valid) {\n e0 = clamp(r.e0, lo, hi);\n e1 = clamp(r.e1, lo, hi);\n fitStream = r.wstream;\n haveFitWeights = true;\n }\n }\n var swapped = false;\n if (e0.x + e0.y + e0.z > e1.x + e1.y + e1.z) {\n let tmp = e0; e0 = e1; e1 = tmp;\n swapped = true;\n }\n let E0 = vec4<u32>(e0);\n let E1 = vec4<u32>(e1);\n\n // Weight pass against the final endpoints, packing on the fly.\n let dir = vec4<f32>(e1 - e0);\n let dd = dot(dir, dir);\n let e0f = vec4<f32>(e0);\n if (opaque) {\n // CEM 8: 3-bit weights, stream bit q = 3k.\n var s0 = 0u; var s1 = 0u;\n if (dd > 0.0) {\n let inv = 7.0 / dd;\n for (var k: u32 = 0u; k < 16u; k = k + 1u) {\n let w = u32(clamp(floor(dot(vec4<f32>(pixels[k]) - e0f, dir) * inv + 0.5), 0.0, 7.0));\n let off = 3u * k;\n if (off < 30u) { s0 = s0 | (w << off); }\n else if (off == 30u) { s0 = s0 | (w << 30u); s1 = s1 | (w >> 2u); }\n else { s1 = s1 | (w << (off - 32u)); }\n }\n }\n // Mode 0x053, CEM 8 @13, endpoints R0 R1 G0 G1 B0 B1 from bit 17.\n w0 = 0x053u | (8u << 13u) | (E0.x << 17u) | (E1.x << 25u);\n w1 = (E1.x >> 7u) | (E0.y << 1u) | (E1.y << 9u) | (E0.z << 17u) | (E1.z << 25u);\n w2 = (E1.z >> 7u) | reverseBits(s1);\n w3 = reverseBits(s0);\n } else {\n // CEM 12: 2-bit weights, stream bit q = 2k (single stream word).\n // Valid fits ship the fit-pass weights; the blue-contraction swap is\n // a full reflection w \u2192 3\u2212w = bitwise NOT of the packed stream (see\n // astc4x4_fast_f16.wgsl for the measured trade).\n var s0 = 0u;\n if (haveFitWeights) {\n s0 = select(fitStream, ~fitStream, swapped);\n } else if (dd > 0.0) {\n let inv = 3.0 / dd;\n for (var k: u32 = 0u; k < 16u; k = k + 1u) {\n let w = u32(clamp(floor(dot(vec4<f32>(pixels[k]) - e0f, dir) * inv + 0.5), 0.0, 3.0));\n s0 = s0 | (w << (2u * k));\n }\n }\n // Mode 0x042, CEM 12 @13, endpoints R0 R1 G0 G1 B0 B1 A0 A1 from 17.\n w0 = 0x042u | (12u << 13u) | (E0.x << 17u) | (E1.x << 25u);\n w1 = (E1.x >> 7u) | (E0.y << 1u) | (E1.y << 9u) | (E0.z << 17u) | (E1.z << 25u);\n w2 = (E1.z >> 7u) | (E0.w << 1u) | (E1.w << 9u);\n w3 = reverseBits(s0);\n }\n }\n\n let out = block_index * 4u;\n dst[out + 0u] = w0;\n dst[out + 1u] = w1;\n dst[out + 2u] = w2;\n dst[out + 3u] = w3;\n}\n";
1396
+ var astc4x4_default = "// ASTC 4\xD74 LDR compute shader encoder.\n//\n// One invocation per 4\xD74 block. Emits 16 bytes = 4 u32s into the storage\n// buffer at `dst[block_index * 4 .. + 3]`. This is the f32 fallback;\n// astc4x4_fast_f16.wgsl is the same algorithm (and documents the design\n// measurements) and is preferred when the device reports shader-f16.\n//\n// ALGORITHM: per-block class selection, then a single-line fit:\n// gray + opaque \u2192 CEM 0 (luminance), 5-bit weights, block mode 0x253 \u2014\n// scalar path: exact min/max endpoints, 32-level weight\n// assignment, no covariance / iteration / refit needed\n// opaque \u2192 CEM 8 (RGB), 3-channel PCA extents, two bit budgets:\n// span > 12 \u2192 QUANT_192 endpoints (trit ISE) + 4-bit\n// weights, mode 0x242; span \u2264 12 \u2192 8-bit endpoints +\n// 3-bit weights, mode 0x053\n// translucent \u2192 CEM 12 (RGBA), 2-bit weights, block mode 0x042 \u2014 PCA\n// seed \u2192 fused projection + least-squares refit, the fit\n// pass's weights shipped\n// The palette entries of a single-line fit are colinear, so the nearest is\n// the rounded projection \u2014 no per-entry search. The endpoint ordering rule\n// is applied before the weight pass, so no reflection is needed.\n//\n// RESTRICTED SUBSET + BLOCK LAYOUT: see astc4x4_ref.ts (single partition,\n// no dual-plane, CEM 0/8/12, plain-bit weights; block mode derivations,\n// the QUANT_192 trit ISE and the weight-stream bit order are documented\n// there).\n//\n// WEIGHT PLACEMENT: stream bit q (bit j of weight k, q = nBits\xB7k + j) lives\n// at block bit 127 \u2212 q, so a stream word assembled LSB-first maps onto a\n// block word with a single reverseBits().\n//\n// ENDPOINT ORDERING: CEM 8/12 decoders branch into blue contraction when\n// sum(e0.rgb) > sum(e1.rgb) (unquantised values); the encoder swaps\n// endpoints up front (weights are assigned after the swap, so no\n// reflection pass). CEM 0 has no rule (L0 \u2264 L1 by construction).\n\nstruct Params {\n blocks_x: u32,\n blocks_y: u32,\n width: u32,\n height: u32,\n y0: u32, // first block row of this dispatch (row-band encodes)\n};\n\n@group(0) @binding(0) var src_tex: texture_2d<f32>;\n@group(0) @binding(1) var<storage, read_write> dst: array<u32>;\n@group(0) @binding(2) var<uniform> params: Params;\n\nfn to8(v: vec4<f32>) -> vec4<i32> {\n return vec4<i32>(clamp(floor(v * 255.0 + 0.5), vec4<f32>(0.0), vec4<f32>(255.0)));\n}\n\n// One pass over the block: project every pixel onto the e0\u2192e1 line\n// (lmax + 1 colinear levels, so the nearest entry is the rounded\n// projection) and accumulate the least-squares normal-equation sums;\n// solve for the refit endpoints. Weights are not produced here \u2014 the\n// caller reprojects against the quantised refit endpoints anyway.\nstruct Fit { e0: vec4<i32>, e1: vec4<i32>, valid: bool, wstream: u32 };\nfn proj_fit(pixels: ptr<function, array<vec4<i32>, 16>>, e0: vec4<i32>, e1: vec4<i32>) -> Fit {\n var out: Fit;\n out.valid = false;\n out.wstream = 0u;\n let dir = vec4<f32>(e1 - e0);\n let dd = dot(dir, dir);\n if (dd == 0.0) { return out; }\n let e0f = vec4<f32>(e0);\n let inv = 3.0 / dd;\n var sAA: f32 = 0.0; var sBB: f32 = 0.0; var sAB: f32 = 0.0;\n var sAV: vec4<f32> = vec4<f32>(0.0); var sBV: vec4<f32> = vec4<f32>(0.0);\n var s_min = 3.0; var s_max = 0.0;\n for (var k: u32 = 0u; k < 16u; k = k + 1u) {\n let v = vec4<f32>((*pixels)[k]);\n let s = clamp(floor(dot(v - e0f, dir) * inv + 0.5), 0.0, 3.0);\n out.wstream = out.wstream | (u32(s) << (2u * k));\n s_min = min(s_min, s); s_max = max(s_max, s);\n let b = s * (1.0 / 3.0); let a = 1.0 - b;\n sAA = sAA + a * a; sBB = sBB + b * b; sAB = sAB + a * b;\n sAV = sAV + a * v; sBV = sBV + b * v;\n }\n // Rank-1 guard: if every pixel projects to ONE level the system is\n // singular \u2014 det and the numerators are pure float rounding noise and the\n // solve returns garbage endpoints. With \u22652 levels det \u2265 15\xB7(1/3)\xB2 \u2248 1.67.\n if (s_min == s_max) { return out; }\n let det = sAA * sBB - sAB * sAB;\n if (abs(det) < 1e-3) { return out; }\n out.e0 = vec4<i32>(clamp(round((sBB * sAV - sAB * sBV) / det), vec4<f32>(0.0), vec4<f32>(255.0)));\n out.e1 = vec4<i32>(clamp(round((sAA * sBV - sAB * sAV) / det), vec4<f32>(0.0), vec4<f32>(255.0)));\n out.valid = true;\n return out;\n}\n\n// Principal colour axis via covariance power-iteration (RGBA, 8-bit integer\n// pixel domain), seeded with the bbox diagonal. Returns a unit axis, or\n// vec4(0) for a degenerate (constant) block. Used to seed the LSQ fit \u2014 the\n// bbox diagonal is sign-blind and points across anti-correlated data (normal\n// maps, hue edges) instead of along it.\nfn principal_axis4(\n pixels: ptr<function, array<vec4<i32>, 16>>,\n mean: vec4<f32>,\n seed: vec4<f32>,\n iters: u32,\n) -> vec4<f32> {\n var c0v = vec4<f32>(0.0);\n var c1v = vec4<f32>(0.0);\n var c2v = vec4<f32>(0.0);\n var c3v = vec4<f32>(0.0);\n for (var k: u32 = 0u; k < 16u; k = k + 1u) {\n let d = vec4<f32>((*pixels)[k]) - mean;\n c0v = c0v + d.x * d;\n c1v = c1v + d.y * d;\n c2v = c2v + d.z * d;\n c3v = c3v + d.w * d;\n }\n var v = seed;\n var len = length(v);\n if (len < 1e-9) { return vec4<f32>(0.0); }\n v = v / len;\n for (var iter: u32 = 0u; iter < iters; iter = iter + 1u) {\n let nv = vec4<f32>(dot(c0v, v), dot(c1v, v), dot(c2v, v), dot(c3v, v));\n len = length(nv);\n if (len < 1e-12) { return vec4<f32>(0.0); }\n v = nv / len;\n }\n return v;\n}\n\n// Principal RGB axis for opaque blocks (alpha is constant there, so the\n// 4th covariance lane is dead weight). Same iteration as principal_axis4.\nfn principal_axis3(\n pixels: ptr<function, array<vec4<i32>, 16>>,\n mean: vec3<f32>,\n seed: vec3<f32>,\n) -> vec3<f32> {\n var c0v = vec3<f32>(0.0);\n var c1v = vec3<f32>(0.0);\n var c2v = vec3<f32>(0.0);\n for (var k: u32 = 0u; k < 16u; k = k + 1u) {\n let d = vec3<f32>((*pixels)[k].xyz) - mean;\n c0v = c0v + d.x * d;\n c1v = c1v + d.y * d;\n c2v = c2v + d.z * d;\n }\n var v = seed;\n var len = length(v);\n if (len < 1e-9) { return vec3<f32>(0.0); }\n v = v / len;\n for (var iter: u32 = 0u; iter < 8u; iter = iter + 1u) {\n let nv = vec3<f32>(dot(c0v, v), dot(c1v, v), dot(c2v, v));\n len = length(nv);\n if (len < 1e-12) { return vec3<f32>(0.0); }\n v = nv / len;\n }\n return v;\n}\n\n// ISE trit-block encoder: 5 trits \u2192 the 8-bit T field (the inverse of the\n// spec's trit-block decode; see astc4x4_ref.ts).\nfn trit_enc(t0: u32, t1: u32, t2: u32, t3: u32, t4: u32) -> u32 {\n let c = select(select((t2 << 4u) | (t1 << 2u) | t0, (t1 << 4u) | (t0 << 2u) | 3u, t2 == 2u), 12u | t0, t2 == 2u && t1 == 2u);\n return select(select((t4 << 7u) | (t3 << 5u) | c, (t3 << 7u) | 96u | c, t4 == 2u), ((c >> 2u) << 5u) | 28u | (c & 3u), t3 == 2u && t4 == 2u);\n}\n\n// Nearest QUANT_192 endpoint to x \u2208 [0,255]; returns (ISE value =\n// trit\xB764 + bits, unquantised level). See astc4x4_fast_f16.wgsl.\nfn q192(x: f32) -> vec2<u32> {\n let v = u32(clamp(floor(x + 0.5), 0.0, 255.0));\n let up = v > 127u;\n var u = select(v, 255u - v, up);\n if ((u & 3u) == 3u) {\n let xu = select(x, 255.0 - x, up);\n u = select(u - 1u, u + 1u, xu > f32(u) && u < 127u);\n }\n return vec2<u32>(((u & 3u) << 6u) | ((u >> 2u) << 1u) | u32(up), select(u, 255u - u, up));\n}\n\n// ------------------------------- Entry ---------------------------------- //\n\n@compute @workgroup_size(8, 8, 1)\nfn encode(@builtin(global_invocation_id) gid_raw: vec3<u32>) {\n // Row-band encodes dispatch a slice of the block grid starting at row y0.\n let gid = vec3<u32>(gid_raw.x, gid_raw.y + params.y0, gid_raw.z);\n if (gid.x >= params.blocks_x || gid.y >= params.blocks_y) {\n return;\n }\n\n let bx = gid.x;\n let by = gid.y;\n let block_index = by * params.blocks_x + bx;\n\n let base = vec2<i32>(i32(bx) * 4, i32(by) * 4);\n let max_xy = vec2<i32>(i32(params.width) - 1, i32(params.height) - 1);\n\n var pixels: array<vec4<i32>, 16>;\n var lo = vec4<i32>(255);\n var hi = vec4<i32>(0);\n var isum = vec4<i32>(0);\n var gd = 0; // max |R\u2212G|, |R\u2212B| over the block; 0 \u21D4 exactly grayscale\n for (var i: u32 = 0u; i < 16u; i = i + 1u) {\n let p = clamp(base + vec2<i32>(i32(i & 3u), i32(i >> 2u)), vec2<i32>(0, 0), max_xy);\n let px = to8(textureLoad(src_tex, p, 0));\n pixels[i] = px;\n lo = min(lo, px);\n hi = max(hi, px);\n isum = isum + px;\n gd = max(gd, max(abs(px.x - px.y), abs(px.x - px.z)));\n }\n let opaque = lo.w == 255;\n\n var w0: u32; var w1: u32; var w2: u32; var w3: u32;\n\n if (opaque && gd == 0) {\n // ---------------- Luminance path: CEM 0, 5-bit weights ----------------\n // Endpoints at the exact extremes; 32 palette levels make an LSQ refit\n // unnecessary.\n let L0 = u32(lo.x);\n let L1 = u32(hi.x);\n var s0 = 0u; var s1 = 0u; var s2 = 0u;\n if (L1 > L0) {\n let sc = 64.0 / f32(hi.x - lo.x);\n // Exact nearest entry of the QUANT_32 grid: unq = 2w for w \u2264 15,\n // 2w + 2 for w \u2265 16 (4-wide gap at the middle, so uniform rounding\n // is wrong there). Best candidate of each half, keep the closer.\n for (var k: u32 = 0u; k < 16u; k = k + 1u) {\n let u = clamp(f32(pixels[k].x - lo.x) * sc, 0.0, 64.0);\n let wlo = clamp(floor(u * 0.5 + 0.5), 0.0, 15.0);\n let whi = clamp(floor((u - 2.0) * 0.5 + 0.5), 16.0, 31.0);\n let pick = abs(u - wlo * 2.0) <= abs(u - (whi * 2.0 + 2.0));\n let w = u32(select(whi, wlo, pick));\n // Stream bit q = 5k + j; straddles handled with constant shifts.\n let off = 5u * k;\n if (off < 28u) { s0 = s0 | (w << off); }\n else if (off == 30u) { s0 = s0 | (w << 30u); s1 = s1 | (w >> 2u); }\n else if (off < 60u) { s1 = s1 | (w << (off - 32u)); }\n else if (off == 60u) { s1 = s1 | (w << 28u); s2 = s2 | (w >> 4u); }\n else { s2 = s2 | (w << (off - 64u)); }\n }\n }\n // Mode 0x253, partitions\u22121 = 0, CEM 0, L0 @17, L1 @25 (top bit spills\n // into w1 bit 0); stream words map onto block words via reverseBits.\n w0 = 0x253u | (L0 << 17u) | (L1 << 25u);\n w1 = (L1 >> 7u) | reverseBits(s2);\n w2 = reverseBits(s1);\n w3 = reverseBits(s0);\n } else if (opaque) {\n // ------------- Opaque colour: CEM 8, two bit budgets -------------------\n // span > 12 \u2192 QUANT_192 endpoints + 4-bit weights (mode 0x242), else\n // exact 8-bit endpoints + 3-bit weights (mode 0x053); endpoints are the\n // bbox-clamped PCA extents. See astc4x4_fast_f16.wgsl for the design\n // and measurements.\n let mean3 = vec3<f32>(isum.xyz) * (1.0 / 16.0);\n let lo3 = vec3<f32>(lo.xyz);\n let hi3 = vec3<f32>(hi.xyz);\n var x0 = lo3;\n var x1 = hi3;\n let axis = principal_axis3(&pixels, mean3, hi3 - lo3);\n if (dot(axis, axis) > 0.0) {\n var t_min: f32 = 1e30;\n var t_max: f32 = -1e30;\n for (var k: u32 = 0u; k < 16u; k = k + 1u) {\n let t = dot(vec3<f32>(pixels[k].xyz) - mean3, axis);\n t_min = min(t_min, t);\n t_max = max(t_max, t);\n }\n x0 = clamp(mean3 + t_min * axis, lo3, hi3);\n x1 = clamp(mean3 + t_max * axis, lo3, hi3);\n }\n let span3 = hi.xyz - lo.xyz;\n let small = max(max(span3.x, span3.y), span3.z) <= 12;\n var r0: vec2<u32>; var g0: vec2<u32>; var b0: vec2<u32>;\n var r1: vec2<u32>; var g1: vec2<u32>; var b1: vec2<u32>;\n if (small) {\n let q0 = vec3<u32>(clamp(floor(x0 + 0.5), vec3<f32>(0.0), vec3<f32>(255.0)));\n let q1 = vec3<u32>(clamp(floor(x1 + 0.5), vec3<f32>(0.0), vec3<f32>(255.0)));\n r0 = vec2<u32>(q0.x); g0 = vec2<u32>(q0.y); b0 = vec2<u32>(q0.z);\n r1 = vec2<u32>(q1.x); g1 = vec2<u32>(q1.y); b1 = vec2<u32>(q1.z);\n } else {\n r0 = q192(x0.x); g0 = q192(x0.y); b0 = q192(x0.z);\n r1 = q192(x1.x); g1 = q192(x1.y); b1 = q192(x1.z);\n }\n // Blue-contraction ordering on the unquantised levels.\n if (r0.y + g0.y + b0.y > r1.y + g1.y + b1.y) {\n let tr = r0; r0 = r1; r1 = tr;\n let tg = g0; g0 = g1; g1 = tg;\n let tb = b0; b0 = b1; b1 = tb;\n }\n let d0 = vec3<f32>(vec3<u32>(r0.y, g0.y, b0.y));\n let d1 = vec3<f32>(vec3<u32>(r1.y, g1.y, b1.y));\n // One weight loop for both budgets; weights as 4-bit nibbles.\n let lmax = select(15.0, 7.0, small);\n let dir = d1 - d0;\n let dd = dot(dir, dir);\n var s0 = 0u; var s1 = 0u;\n if (dd > 0.0) {\n let inv = lmax / dd;\n for (var k: u32 = 0u; k < 8u; k = k + 1u) {\n let w = u32(clamp(floor(dot(vec3<f32>(pixels[k].xyz) - d0, dir) * inv + 0.5), 0.0, lmax));\n s0 = s0 | (w << (4u * k));\n }\n for (var k: u32 = 8u; k < 16u; k = k + 1u) {\n let w = u32(clamp(floor(dot(vec3<f32>(pixels[k].xyz) - d0, dir) * inv + 0.5), 0.0, lmax));\n s1 = s1 | (w << (4u * (k - 8u)));\n }\n }\n if (small) {\n // Mode 0x053: plain 8-bit endpoints; the 3-bit weight stream is the\n // nibbles compacted (8 nibbles \u2192 24 bits).\n var c0 = (s0 & 0x07070707u) | ((s0 & 0x70707070u) >> 1u);\n c0 = (c0 & 0x003F003Fu) | ((c0 & 0x3F003F00u) >> 2u);\n c0 = (c0 & 0x00000FFFu) | ((c0 & 0x0FFF0000u) >> 4u);\n var c1 = (s1 & 0x07070707u) | ((s1 & 0x70707070u) >> 1u);\n c1 = (c1 & 0x003F003Fu) | ((c1 & 0x3F003F00u) >> 2u);\n c1 = (c1 & 0x00000FFFu) | ((c1 & 0x0FFF0000u) >> 4u);\n w0 = 0x053u | (8u << 13u) | (r0.x << 17u) | (r1.x << 25u);\n w1 = (r1.x >> 7u) | (g0.x << 1u) | (g1.x << 9u) | (b0.x << 17u) | (b1.x << 25u);\n w2 = (b1.x >> 7u) | reverseBits(c1 >> 8u);\n w3 = reverseBits(c0 | (c1 << 24u));\n } else {\n // Mode 0x242: trit-ISE QUANT_192 endpoints (v0..v5 = R0 R1 G0 G1 B0\n // B1, 46 bits from bit 17: group 1 = v0..v4 with trit field T, group\n // 2 = v5 with its lone trit as 2 bits), 4-bit weights.\n let tg = trit_enc(r0.x >> 6u, r1.x >> 6u, g0.x >> 6u, g1.x >> 6u, b0.x >> 6u);\n w0 = 0x242u | (8u << 13u) | ((r0.x & 63u) << 17u) | ((tg & 3u) << 23u) | ((r1.x & 63u) << 25u) | (((tg >> 2u) & 1u) << 31u);\n w1 = ((tg >> 3u) & 1u) | ((g0.x & 63u) << 1u) | (((tg >> 4u) & 1u) << 7u) | ((g1.x & 63u) << 8u)\n | (((tg >> 5u) & 3u) << 14u) | ((b0.x & 63u) << 16u) | ((tg >> 7u) << 22u) | ((b1.x & 63u) << 23u)\n | ((b1.x >> 6u) << 29u);\n w2 = reverseBits(s1);\n w3 = reverseBits(s0);\n }\n } else {\n // ------------- Translucent: CEM 12, 2-bit weights, PCA seed + refit ----\n let mean = vec4<f32>(isum) * (1.0 / 16.0);\n\n // Fused LSQ fit seeded from the block's principal RGBA axis (4 power\n // iterations \u2014 the refit absorbs residual axis error) at the exact\n // projection extents. The refit is clamped to the block bbox: on\n // multi-cluster blocks the unconstrained solve extrapolates outside the\n // block's colours and the per-channel clamp would bend the hue.\n var seed0 = lo;\n var seed1 = hi;\n let axis = principal_axis4(&pixels, mean, vec4<f32>(hi - lo), 4u);\n if (dot(axis, axis) > 0.0) {\n var t_min: f32 = 1e30;\n var t_max: f32 = -1e30;\n for (var k: u32 = 0u; k < 16u; k = k + 1u) {\n let t = dot(vec4<f32>(pixels[k]) - mean, axis);\n t_min = min(t_min, t);\n t_max = max(t_max, t);\n }\n seed0 = vec4<i32>(clamp(round(mean + t_min * axis), vec4<f32>(0.0), vec4<f32>(255.0)));\n seed1 = vec4<i32>(clamp(round(mean + t_max * axis), vec4<f32>(0.0), vec4<f32>(255.0)));\n }\n var e0 = lo;\n var e1 = hi;\n var fitStream = 0u;\n var haveFitWeights = false;\n let r = proj_fit(&pixels, seed0, seed1);\n if (r.valid) {\n e0 = clamp(r.e0, lo, hi);\n e1 = clamp(r.e1, lo, hi);\n fitStream = r.wstream;\n haveFitWeights = true;\n }\n var swapped = false;\n if (e0.x + e0.y + e0.z > e1.x + e1.y + e1.z) {\n let tmp = e0; e0 = e1; e1 = tmp;\n swapped = true;\n }\n let E0 = vec4<u32>(e0);\n let E1 = vec4<u32>(e1);\n // Valid fits ship the fit-pass weights; the blue-contraction swap is a\n // full reflection w \u2192 3\u2212w = bitwise NOT of the packed stream.\n var s0 = 0u;\n if (haveFitWeights) {\n s0 = select(fitStream, ~fitStream, swapped);\n } else {\n let dir = vec4<f32>(e1 - e0);\n let dd = dot(dir, dir);\n let e0f = vec4<f32>(e0);\n if (dd > 0.0) {\n let inv = 3.0 / dd;\n for (var k: u32 = 0u; k < 16u; k = k + 1u) {\n let w = u32(clamp(floor(dot(vec4<f32>(pixels[k]) - e0f, dir) * inv + 0.5), 0.0, 3.0));\n s0 = s0 | (w << (2u * k));\n }\n }\n }\n // Mode 0x042, CEM 12 @13, endpoints R0 R1 G0 G1 B0 B1 A0 A1 from 17.\n w0 = 0x042u | (12u << 13u) | (E0.x << 17u) | (E1.x << 25u);\n w1 = (E1.x >> 7u) | (E0.y << 1u) | (E1.y << 9u) | (E0.z << 17u) | (E1.z << 25u);\n w2 = (E1.z >> 7u) | (E0.w << 1u) | (E1.w << 9u);\n w3 = reverseBits(s0);\n }\n\n let out = block_index * 4u;\n dst[out + 0u] = w0;\n dst[out + 1u] = w1;\n dst[out + 2u] = w2;\n dst[out + 3u] = w3;\n}\n";
1544
1397
 
1545
1398
  // src/astc4x4_fast_f16.wgsl
1546
- var astc4x4_fast_f16_default = `// astc4x4 "fast" encoder \u2014 f16 variant (requires the shader-f16 feature).
1547
- // Same algorithm family as the f32 fallback in astc4x4.wgsl (per-block
1548
- // class selection, principal-axis seed \u2192 projection weight assignment with
1549
- // a fused least-squares refit \u2192 reproject), tuned for throughput:
1399
+ var astc4x4_fast_f16_default = `// astc4x4 encoder \u2014 f16 variant (requires the shader-f16 feature). Same
1400
+ // algorithm as the f32 fallback in astc4x4.wgsl, with the projection / fit
1401
+ // math in f16:
1550
1402
  //
1551
1403
  // \u2022 THREE block classes, picked per block from the loaded pixels (the
1552
1404
  // ASTC bit budget trades endpoint bits against weight bits, so a block
1553
1405
  // only pays for the channels it uses):
1554
1406
  // gray + opaque \u2192 CEM 0 (luminance), 5-bit weights, mode 0x253
1555
- // opaque \u2192 CEM 8 (RGB), 3-bit weights, mode 0x053
1556
- // translucent \u2192 CEM 12 (RGBA), 2-bit weights, mode 0x042
1407
+ // opaque \u2192 CEM 8 (RGB), two budgets:
1408
+ // span > 12 \u2192 QUANT_192 endpoints + 4-bit
1409
+ // weights (QUANT_16), mode 0x242
1410
+ // span \u2264 12 \u2192 QUANT_256 endpoints + 3-bit
1411
+ // weights (QUANT_8), mode 0x053
1412
+ // translucent \u2192 CEM 12 (RGBA), 2-bit weights, mode 0x042
1557
1413
  // "gray" = every texel R == G == B exactly (f16 equality is exact for
1558
- // 8-bit sources), "opaque" = every texel A == 1. The gray path is
1559
- // scalar \u2014 no covariance, no power iteration, no 4-D fit \u2014 and runs
1560
- // [0,255]-integer f16 math like the BC5 kernel (exact endpoints, and
1561
- // 64/span \u2264 64 never overflows f16, unlike a [0,1]-domain 1/dd).
1562
- // \u2022 All projection / refit math in f16 ([0,1] domain, colour paths).
1563
- // The projection direction is pre-scaled by 32 \u2014 a shallow block
1564
- // (endpoints ~1/255 apart) has dd \u2248 1.5e-5, where 3/dd \u2248 2e5 overflows
1565
- // f16 (max 65504) to +inf and the projection dots go subnormal,
1566
- // turning weights and the LSQ refit to garbage (banding on smooth
1567
- // gradients). Scaling dir by 32 puts every intermediate in f16's
1568
- // normal range; s = dot\xB7(32\xB7L/dd\u2083\u2082) is the same quantity (worst case
1569
- // for L = 7 levels: inv = 224/0.0157 \u2248 1.4e4).
1570
- // \u2022 Opaque blocks ship the quantised PCA extents directly (no LSQ fit
1571
- // pass, 8 power iterations \u2014 see the endpoint-selection comment); only
1572
- // the translucent CEM 12 path still refits, and its seed pass only
1573
- // accumulates the LSQ sums \u2014 final weights always come from a
1574
- // reprojection against the final endpoints.
1414
+ // 8-bit sources), "opaque" = every texel A == 1. Every class fills the
1415
+ // 128-bit block; the old opaque layout (8-bit endpoints + 3-bit weights
1416
+ // for every block) left 15 bits unused.
1417
+ // \u2022 OPAQUE BUDGETS. 16 weight levels need QUANT_192 endpoints (46 bits)
1418
+ // to fit \u2014 trit-ISE coded, 1/4 of the 8-bit values fall on excluded
1419
+ // slots and round to a neighbour. On wide blocks the finer weights win
1420
+ // by far (+0.6..1.2 dB on photographic colour); when the block's colour
1421
+ // span is small the endpoint error dominates instead (the weight error
1422
+ // scales with the span, the endpoint error doesn't), and exact 8-bit
1423
+ // endpoints with 8 levels win (the 12-level threshold was swept:
1424
+ // 8/12/16/24 on the /eval corpus). Both budgets share ONE weight loop
1425
+ // (weights accumulate as 4-bit nibbles; the 3-bit stream is compacted
1426
+ // afterwards) \u2014 two loops measured ~8% slower on mixed warps.
1427
+ // \u2022 The opaque path runs in 3-channel math (alpha is constant): the dead
1428
+ // 4th lane in the covariance, power iteration, extents and weight
1429
+ // loops cost ~10% of the kernel. Opaque endpoints are the PCA extents
1430
+ // (8 power iterations; 6 measured \u22120.1 dB on normal maps), bbox-clamped
1431
+ // \u2014 no LSQ refit: with 8\u201316 weight levels a converged axis carries the
1432
+ // quality.
1433
+ // \u2022 QUANT_16 weight levels are not uniform (0 4 8 12 17 21 25 29 35 \u2026);
1434
+ // the weights are the uniformly rounded projection anyway \u2014 the exact
1435
+ // nearest-level mapping measured +0.03..0.06 dB for +15% GPU.
1436
+ // \u2022 The GRAY path is scalar \u2014 no covariance, no power iteration \u2014 in
1437
+ // [0,255]-integer f16 math (exact endpoints, and 64/span \u2264 64 never
1438
+ // overflows f16, unlike a [0,1]-domain 1/dd).
1439
+ // \u2022 TRANSLUCENT blocks: PCA seed (4 power iterations) \u2192 fused projection
1440
+ // + LSQ refit (the 4-level grid is coarse enough to need it, and the fit
1441
+ // pulls the line through the dominant cluster of multi-cluster blocks)
1442
+ // \u2192 the fit-pass weights are shipped without a reprojection. A
1443
+ // 3-bit-weight + QUANT_192 translucent budget measured +1.5 dB on
1444
+ // translucent content but +10..20% GPU there \u2014 not taken.
1445
+ // \u2022 f16 range: projection directions are pre-scaled by 32 \u2014 a shallow
1446
+ // block (endpoints ~1/255 apart) has dd \u2248 1.5e-5, where L/dd overflows
1447
+ // f16 (max 65504) and the projection dots go subnormal, turning weights
1448
+ // and the refit to garbage (banding on smooth gradients). Scaled, every
1449
+ // intermediate stays in f16's normal range (worst case, 16 levels:
1450
+ // inv = 480/0.0157 \u2248 3.1e4).
1575
1451
  // \u2022 Endpoint ordering (the blue-contraction rule: sum(e0.rgb) must not
1576
- // exceed sum(e1.rgb)) is applied BEFORE the final projection, so no
1577
- // weight-reflection pass is needed.
1452
+ // exceed sum(e1.rgb), compared on the UNQUANTISED values) is applied
1453
+ // BEFORE the weight pass, so no weight-reflection pass is needed.
1578
1454
  // \u2022 Weight streams are accumulated LSB-first into u32 words and placed
1579
1455
  // into the block's reversed-bit-order field with reverseBits() \u2014
1580
- // stream bit q lives at block bit 127 \u2212 q, so a whole stream word
1581
- // maps onto a block word with a single bit reversal.
1582
- // \u2022 The covariance moments stay in their OWN pass, deliberately: fusing
1583
- // them into the load loop bc7-style (pixel-0 residuals, with or without
1584
- // hoisting pixel 0 out of the loop) measured +5% GPU time on 4096\xB2
1585
- // (/ab A/B, 2026-07, M3) \u2014 the separate loop overlaps the 16 texture
1586
- // loads' latency better than a longer in-loop dependency chain does.
1587
- // Per-pass cost on the same rig, for future tuning: covariance+power-
1588
- // iteration \u2248 18%, LSQ fit pass \u2248 24%, extents pass \u2248 6% of the kernel;
1589
- // @workgroup_size 16\xD78 measured exactly at par with 8\xD78.
1456
+ // stream bit q lives at block bit 127 \u2212 q.
1590
1457
  //
1591
- // RESTRICTED SUBSET + BLOCK LAYOUT: see astc4x4_ref.ts (single partition,
1592
- // CEM 0/8/12, 8-bit endpoints, plain-bit weight ISE, block modes
1593
- // 0x253/0x053/0x042).
1458
+ // BLOCK LAYOUTS + ISE: see astc4x4_ref.ts (single partition, CEM 0/8/12,
1459
+ // trit-ISE QUANT_192 endpoints, plain-bit weights, block modes
1460
+ // 0x253/0x242/0x053/0x042).
1594
1461
  //
1595
1462
  // The host selects this module only when the device reports shader-f16,
1596
1463
  // falling back to astc4x4.wgsl otherwise.
1597
1464
  enable f16;
1598
1465
  alias h = f16;
1466
+ alias h3 = vec3<f16>;
1599
1467
  alias h4 = vec4<f16>;
1600
- struct Params { blocks_x: u32, blocks_y: u32, width: u32, height: u32, };
1468
+ struct Params { blocks_x: u32, blocks_y: u32, width: u32, height: u32, y0: u32, };
1601
1469
  @group(0) @binding(0) var src_tex: texture_2d<f32>;
1602
1470
  @group(0) @binding(1) var<storage, read_write> dst: array<u32>;
1603
1471
  @group(0) @binding(2) var<uniform> params: Params;
1604
1472
 
1605
1473
  // Fused projection + least-squares refit against the 4-level QUANT_4
1606
- // palette \u2014 only the translucent (CEM 12) path still refits: the 2-bit
1607
- // weight grid is coarse enough to need it, while the opaque paths get
1608
- // more from spending the same time elsewhere (see header).
1474
+ // palette (translucent blocks). Returns the refit endpoints and the
1475
+ // projection's packed weight stream.
1609
1476
  struct Fit { e0: h4, e1: h4, valid: bool, wstream: u32 };
1610
1477
  fn proj_fit(pix: ptr<function, array<h4, 16>>, e0: h4, e1: h4) -> Fit {
1611
1478
  var out: Fit;
1612
1479
  out.valid = false;
1613
1480
  out.wstream = 0u;
1614
- // dir pre-scaled by 32 to keep dd and the projection dots in f16's normal
1615
- // range (see header). Spans below ~0.7 of an 8-bit step (dd\u2083\u2082 < 0.008,
1616
- // possible only for non-8-bit sources) are treated as flat.
1481
+ // Spans below ~0.7 of an 8-bit step (dd\u2083\u2082 < 0.008, possible only for
1482
+ // non-8-bit sources) are treated as flat.
1617
1483
  let dir = (e1 - e0) * h(32.0);
1618
1484
  let dd = dot(dir, dir);
1619
1485
  if (dd < h(0.008)) { return out; }
@@ -1621,7 +1487,7 @@ fn proj_fit(pix: ptr<function, array<h4, 16>>, e0: h4, e1: h4) -> Fit {
1621
1487
  var sAA = h(0.0); var sBB = h(0.0); var sAB = h(0.0);
1622
1488
  var sAV = h4(0.0); var sBV = h4(0.0);
1623
1489
  var s_min = h(3.0); var s_max = h(0.0);
1624
- // Value sums accumulate v \u2212 e0 (basis is affine, a + b = 1, so the fit
1490
+ // Value sums accumulate v \u2212 e0 (the basis is affine, a + b = 1, so the fit
1625
1491
  // commutes with the shift): accumulators scale with the block span, keeping
1626
1492
  // f16 rounding a fraction of the span instead of \xB11 level at high absolute
1627
1493
  // values.
@@ -1636,9 +1502,9 @@ fn proj_fit(pix: ptr<function, array<h4, 16>>, e0: h4, e1: h4) -> Fit {
1636
1502
  sAV = sAV + a * vr; sBV = sBV + b * vr;
1637
1503
  }
1638
1504
  // Rank-1 guard: if every pixel projects to ONE level the system is
1639
- // singular \u2014 det/numerators are pure f16 rounding noise and the solve
1640
- // returns garbage endpoints. With \u22652 distinct levels
1641
- // det = \u03A3_i<j (b_j \u2212 b_i)\xB2 \u2265 15\xB7(1/3)\xB2 \u2248 1.67 \u2014 0.5 separates cleanly.
1505
+ // singular \u2014 det/numerators are pure f16 rounding noise. With \u22652 distinct
1506
+ // levels det = \u03A3_i<j (b_j \u2212 b_i)\xB2 \u2265 15\xB7(1/3)\xB2 \u2248 1.67 \u2014 0.5 separates
1507
+ // cleanly.
1642
1508
  if (s_min == s_max) { return out; }
1643
1509
  let det = sAA * sBB - sAB * sAB;
1644
1510
  if (abs(det) < h(0.5)) { return out; }
@@ -1648,12 +1514,39 @@ fn proj_fit(pix: ptr<function, array<h4, 16>>, e0: h4, e1: h4) -> Fit {
1648
1514
  return out;
1649
1515
  }
1650
1516
 
1517
+ // ISE trit-block encoder: 5 trits \u2192 the 8-bit T field (the inverse of the
1518
+ // spec's trit-block decode; all 243 tuples round-trip \u2014 see the ref tests).
1519
+ fn trit_enc(t0: u32, t1: u32, t2: u32, t3: u32, t4: u32) -> u32 {
1520
+ let c = select(select((t2 << 4u) | (t1 << 2u) | t0, (t1 << 4u) | (t0 << 2u) | 3u, t2 == 2u), 12u | t0, t2 == 2u && t1 == 2u);
1521
+ return select(select((t4 << 7u) | (t3 << 5u) | c, (t3 << 7u) | 96u | c, t4 == 2u), ((c >> 2u) << 5u) | 28u | (c & 3u), t3 == 2u && t4 == 2u);
1522
+ }
1523
+
1524
+ // Nearest QUANT_192 endpoint (one trit + 6 bits) to x \u2208 [0,255]. The 192
1525
+ // unquantised levels are every value \u2264 127 that is not \u2261 3 (mod 4), plus
1526
+ // their mirror images 255 \u2212 u above: lower-half value u = 4q + t (t < 3)
1527
+ // is ISE value trit t, bits q << 1; the upper half sets bit 0 (the spec's
1528
+ // A-mask inversion). A value that falls on an excluded slot moves to the
1529
+ // nearer representable neighbour (a fixed direction instead costs up to
1530
+ // 0.4 dB on smooth content). Returns (ISE value = trit\xB764 + bits, level).
1531
+ fn q192(x: f32) -> vec2<u32> {
1532
+ let v = u32(clamp(floor(x + 0.5), 0.0, 255.0));
1533
+ let up = v > 127u;
1534
+ var u = select(v, 255u - v, up);
1535
+ if ((u & 3u) == 3u) {
1536
+ let xu = select(x, 255.0 - x, up);
1537
+ u = select(u - 1u, u + 1u, xu > f32(u) && u < 127u);
1538
+ }
1539
+ return vec2<u32>(((u & 3u) << 6u) | ((u >> 2u) << 1u) | u32(up), select(u, 255u - u, up));
1540
+ }
1541
+
1651
1542
  fn q8(e: h4) -> vec4<u32> {
1652
1543
  return vec4<u32>(clamp(floor(e * h(255.0) + h(0.5)), h4(0.0), h4(255.0)));
1653
1544
  }
1654
1545
 
1655
1546
  @compute @workgroup_size(8, 8, 1)
1656
- fn encode(@builtin(global_invocation_id) gid: vec3<u32>) {
1547
+ fn encode(@builtin(global_invocation_id) gid_raw: vec3<u32>) {
1548
+ // Row-band encodes dispatch a slice of the block grid starting at row y0.
1549
+ let gid = vec3<u32>(gid_raw.x, gid_raw.y + params.y0, gid_raw.z);
1657
1550
  if (gid.x >= params.blocks_x || gid.y >= params.blocks_y) { return; }
1658
1551
  let bi = gid.y * params.blocks_x + gid.x;
1659
1552
  let base = vec2<i32>(i32(gid.x) * 4, i32(gid.y) * 4);
@@ -1716,20 +1609,136 @@ fn encode(@builtin(global_invocation_id) gid: vec3<u32>) {
1716
1609
  w1 = (L1 >> 7u) | reverseBits(s2);
1717
1610
  w2 = reverseBits(s1);
1718
1611
  w3 = reverseBits(s0);
1719
- } else {
1720
- // ------------- Colour paths: shared PCA seed + LSQ refit --------------
1612
+ } else if (opaque) {
1613
+ // ------------- Opaque colour: CEM 8, two bit budgets (header) -------------
1614
+ // 3-channel math throughout (alpha is constant 1 here, so a 4-lane
1615
+ // pass would carry a dead lane through the covariance, iteration,
1616
+ // extents and weight loops).
1617
+ //
1721
1618
  // Seed endpoints from the block's principal colour axis (covariance
1722
1619
  // power-iteration, seeded with the bbox diagonal). The bbox diagonal is
1723
1620
  // sign-blind: on anti-correlated channels (normal maps, hue edges) it
1724
- // points across the data instead of along it, and the LSQ refit \u2014 which
1725
- // fits endpoints GIVEN the projection weights \u2014 can't recover from a
1726
- // wrong axis. Deviations are pre-scaled \xD716 so covariance entries for
1727
- // shallow blocks stay in f16's normal range (span ~1/255 \u2192 d\xB2 \u2248 1e-3)
1728
- // while full-range sums stay \u22644096; the iteration renormalises by the
1729
- // max component (a plain length() of the matvec output could overflow
1730
- // f16), so only the direction survives.
1731
- var seed_lo = lo;
1732
- var seed_hi = hi;
1621
+ // points across the data instead of along it. Deviations are
1622
+ // pre-scaled \xD716 so covariance entries for shallow blocks stay in f16's
1623
+ // normal range (span ~1/255 \u2192 d\xB2 \u2248 1e-3) while full-range sums stay
1624
+ // \u22644096; the iteration renormalises by the max component (a plain
1625
+ // length() of the matvec output could overflow f16), so only the
1626
+ // direction survives.
1627
+ let lo3 = lo.xyz;
1628
+ let hi3 = hi.xyz;
1629
+ let mean3 = mean.xyz;
1630
+ var c0v = h3(0.0);
1631
+ var c1v = h3(0.0);
1632
+ var c2v = h3(0.0);
1633
+ for (var k: u32 = 0u; k < 16u; k = k + 1u) {
1634
+ let d = (pix[k].xyz - mean3) * h(16.0);
1635
+ c0v = c0v + d.x * d;
1636
+ c1v = c1v + d.y * d;
1637
+ c2v = c2v + d.z * d;
1638
+ }
1639
+ var seed_lo = lo3;
1640
+ var seed_hi = hi3;
1641
+ var axis = hi3 - lo3;
1642
+ var axis_ok = true;
1643
+ // 8 iterations: the axis IS the endpoint quality here (no refit).
1644
+ for (var it: u32 = 0u; it < 8u; it = it + 1u) {
1645
+ let nv = h3(dot(c0v, axis), dot(c1v, axis), dot(c2v, axis));
1646
+ let m = max(max(abs(nv.x), abs(nv.y)), abs(nv.z));
1647
+ if (m < h(1e-4)) { axis_ok = false; break; }
1648
+ axis = nv / m;
1649
+ }
1650
+ if (axis_ok) {
1651
+ axis = axis / length(axis);
1652
+ var t_min = h(4.0);
1653
+ var t_max = h(-4.0);
1654
+ for (var k: u32 = 0u; k < 16u; k = k + 1u) {
1655
+ let t = dot(pix[k].xyz - mean3, axis);
1656
+ t_min = min(t_min, t);
1657
+ t_max = max(t_max, t);
1658
+ }
1659
+ seed_lo = mean3 + t_min * axis;
1660
+ seed_hi = mean3 + t_max * axis;
1661
+ }
1662
+ // Endpoints: the PCA extents, bbox-clamped (on multi-cluster blocks the
1663
+ // axis extents overshoot the data per channel and would decode to
1664
+ // colours that exist nowhere in the block).
1665
+ let x0 = vec3<f32>(clamp(seed_lo, lo3, hi3)) * 255.0;
1666
+ let x1 = vec3<f32>(clamp(seed_hi, lo3, hi3)) * 255.0;
1667
+ // Bit budget: span \u2264 12 \u2192 exact QUANT_256 endpoints + 8 weight levels
1668
+ // (mode 0x053), wider \u2192 QUANT_192 endpoints + 16 levels (mode 0x242).
1669
+ let span3 = hi3 - lo3;
1670
+ let small = max(max(span3.x, span3.y), span3.z) <= h(12.0 / 255.0);
1671
+ var r0: vec2<u32>; var g0: vec2<u32>; var b0: vec2<u32>;
1672
+ var r1: vec2<u32>; var g1: vec2<u32>; var b1: vec2<u32>;
1673
+ if (small) {
1674
+ let e0 = vec3<u32>(clamp(floor(x0 + 0.5), vec3<f32>(0.0), vec3<f32>(255.0)));
1675
+ let e1 = vec3<u32>(clamp(floor(x1 + 0.5), vec3<f32>(0.0), vec3<f32>(255.0)));
1676
+ r0 = vec2<u32>(e0.x); g0 = vec2<u32>(e0.y); b0 = vec2<u32>(e0.z);
1677
+ r1 = vec2<u32>(e1.x); g1 = vec2<u32>(e1.y); b1 = vec2<u32>(e1.z);
1678
+ } else {
1679
+ r0 = q192(x0.x); g0 = q192(x0.y); b0 = q192(x0.z);
1680
+ r1 = q192(x1.x); g1 = q192(x1.y); b1 = q192(x1.z);
1681
+ }
1682
+ // Blue-contraction ordering on the UNQUANTISED levels, before the
1683
+ // weight pass so the weights come out oriented (no reflection).
1684
+ if (r0.y + g0.y + b0.y > r1.y + g1.y + b1.y) {
1685
+ let tr = r0; r0 = r1; r1 = tr;
1686
+ let tg = g0; g0 = g1; g1 = tg;
1687
+ let tb = b0; b0 = b1; b1 = tb;
1688
+ }
1689
+ let d0 = h3(vec3<f32>(vec3<u32>(r0.y, g0.y, b0.y))) * h(1.0 / 255.0);
1690
+ let d1 = h3(vec3<f32>(vec3<u32>(r1.y, g1.y, b1.y))) * h(1.0 / 255.0);
1691
+ // Weight pass \u2014 ONE loop for both budgets (mixed warps would otherwise
1692
+ // run two): weights land as 4-bit nibbles, levels = 16 or 8. \xD732
1693
+ // pre-scale as in the other formats (distinct levels are \u22651/255 apart,
1694
+ // so dd\u2083\u2082 \u2265 0.0157 and the flat threshold only catches identical ones).
1695
+ let lmax = select(h(15.0), h(7.0), small);
1696
+ let dir = (d1 - d0) * h(32.0);
1697
+ let dd = dot(dir, dir);
1698
+ var s0 = 0u; var s1 = 0u;
1699
+ if (dd >= h(0.008)) {
1700
+ let inv = lmax * h(32.0) / dd;
1701
+ for (var k: u32 = 0u; k < 8u; k = k + 1u) {
1702
+ let w = u32(clamp(floor(dot(pix[k].xyz - d0, dir) * inv + h(0.5)), h(0.0), lmax));
1703
+ s0 = s0 | (w << (4u * k));
1704
+ }
1705
+ for (var k: u32 = 8u; k < 16u; k = k + 1u) {
1706
+ let w = u32(clamp(floor(dot(pix[k].xyz - d0, dir) * inv + h(0.5)), h(0.0), lmax));
1707
+ s1 = s1 | (w << (4u * (k - 8u)));
1708
+ }
1709
+ }
1710
+ if (small) {
1711
+ // Mode 0x053: endpoints as plain bytes from bit 17; the 3-bit weight
1712
+ // stream (48 bits) is the nibbles compacted: 8 nibbles \u2192 24 bits.
1713
+ var c0 = (s0 & 0x07070707u) | ((s0 & 0x70707070u) >> 1u);
1714
+ c0 = (c0 & 0x003F003Fu) | ((c0 & 0x3F003F00u) >> 2u);
1715
+ c0 = (c0 & 0x00000FFFu) | ((c0 & 0x0FFF0000u) >> 4u);
1716
+ var c1 = (s1 & 0x07070707u) | ((s1 & 0x70707070u) >> 1u);
1717
+ c1 = (c1 & 0x003F003Fu) | ((c1 & 0x3F003F00u) >> 2u);
1718
+ c1 = (c1 & 0x00000FFFu) | ((c1 & 0x0FFF0000u) >> 4u);
1719
+ w0 = 0x053u | (8u << 13u) | (r0.x << 17u) | (r1.x << 25u);
1720
+ w1 = (r1.x >> 7u) | (g0.x << 1u) | (g1.x << 9u) | (b0.x << 17u) | (b1.x << 25u);
1721
+ w2 = (b1.x >> 7u) | reverseBits(c1 >> 8u);
1722
+ w3 = reverseBits(c0 | (c1 << 24u));
1723
+ } else {
1724
+ // Mode 0x242, CEM 8 @13. Endpoint ISE (v0..v5 = R0 R1 G0 G1 B0 B1,
1725
+ // 46 bits from bit 17): group 1 = v0..v4 with trit field T, group 2 =
1726
+ // v5 with its lone trit as 2 bits (T of (t5,0,0,0,0) is t5 itself).
1727
+ // Weight stream bit q = 4k + j lives at block bit 127 \u2212 q.
1728
+ let tg = trit_enc(r0.x >> 6u, r1.x >> 6u, g0.x >> 6u, g1.x >> 6u, b0.x >> 6u);
1729
+ w0 = 0x242u | (8u << 13u) | ((r0.x & 63u) << 17u) | ((tg & 3u) << 23u) | ((r1.x & 63u) << 25u) | (((tg >> 2u) & 1u) << 31u);
1730
+ w1 = ((tg >> 3u) & 1u) | ((g0.x & 63u) << 1u) | (((tg >> 4u) & 1u) << 7u) | ((g1.x & 63u) << 8u)
1731
+ | (((tg >> 5u) & 3u) << 14u) | ((b0.x & 63u) << 16u) | ((tg >> 7u) << 22u) | ((b1.x & 63u) << 23u)
1732
+ | ((b1.x >> 6u) << 29u);
1733
+ w2 = reverseBits(s1);
1734
+ w3 = reverseBits(s0);
1735
+ }
1736
+ } else {
1737
+ // ------ Translucent: CEM 12, 2-bit weights, PCA seed + LSQ refit ------
1738
+ // Seed from the principal RGBA axis (covariance power iteration, bbox
1739
+ // diagonal start \u2014 sign-blind on anti-correlated channels, hence the
1740
+ // iteration). Deviations pre-scaled \xD716 (f16 normal range for shallow
1741
+ // blocks, full-range sums \u22644096); renormalised by the max component.
1733
1742
  var c0v = h4(0.0);
1734
1743
  var c1v = h4(0.0);
1735
1744
  var c2v = h4(0.0);
@@ -1741,29 +1750,18 @@ fn encode(@builtin(global_invocation_id) gid: vec3<u32>) {
1741
1750
  c2v = c2v + d.z * d;
1742
1751
  c3v = c3v + d.w * d;
1743
1752
  }
1753
+ var seed_lo = lo;
1754
+ var seed_hi = hi;
1744
1755
  var axis = hi - lo;
1745
1756
  var axis_ok = true;
1746
- // 4 shared iterations, then 4 more for opaque blocks only \u2014 two
1747
- // FIXED-bound loops rather than one divergent trip count, so both
1748
- // unroll. Opaque blocks need the converged axis (it IS the endpoint
1749
- // quality there; 4 was under-converged on noisy 4-D content), while
1750
- // translucent blocks' LSQ refit absorbs residual axis error \u2014 their
1751
- // extra 4 steps measured exactly 0.000 dB on the alpha card for
1752
- // ~5% GPU.
1757
+ // 4 iterations: the refit absorbs residual axis error (4 more measured
1758
+ // 0.000 dB on the alpha card).
1753
1759
  for (var it: u32 = 0u; it < 4u; it = it + 1u) {
1754
1760
  let nv = h4(dot(c0v, axis), dot(c1v, axis), dot(c2v, axis), dot(c3v, axis));
1755
1761
  let m = max(max(abs(nv.x), abs(nv.y)), max(abs(nv.z), abs(nv.w)));
1756
1762
  if (m < h(1e-4)) { axis_ok = false; break; }
1757
1763
  axis = nv / m;
1758
1764
  }
1759
- if (axis_ok && opaque) {
1760
- for (var it: u32 = 0u; it < 4u; it = it + 1u) {
1761
- let nv = h4(dot(c0v, axis), dot(c1v, axis), dot(c2v, axis), dot(c3v, axis));
1762
- let m = max(max(abs(nv.x), abs(nv.y)), max(abs(nv.z), abs(nv.w)));
1763
- if (m < h(1e-4)) { axis_ok = false; break; }
1764
- axis = nv / m;
1765
- }
1766
- }
1767
1765
  if (axis_ok) {
1768
1766
  axis = axis / length(axis);
1769
1767
  var t_min = h(4.0);
@@ -1776,100 +1774,52 @@ fn encode(@builtin(global_invocation_id) gid: vec3<u32>) {
1776
1774
  seed_lo = clamp(mean + t_min * axis, h4(0.0), h4(1.0));
1777
1775
  seed_hi = clamp(mean + t_max * axis, h4(0.0), h4(1.0));
1778
1776
  }
1779
-
1780
- // Endpoint selection. OPAQUE blocks (CEM 8, 8-level weights) quantise
1781
- // the PCA-extent seed directly, BC7-style \u2014 the LSQ fit pass measured
1782
- // +26% GPU for \u22120.06..+0.31 dB against the converged 8-step axis
1783
- // (/ab + PSNR A/B, 2026-07): at 8 weight levels the projection is fine
1784
- // enough that a good axis, not a refit, carries the quality.
1785
- // TRANSLUCENT blocks (CEM 12) keep the fit: 4 levels are coarse enough
1786
- // that dropping it costs 0.5+ dB. Its result is clamped to the block
1787
- // bbox: on multi-cluster blocks the unconstrained solve extrapolates
1788
- // far outside the block's colours and the per-channel [0,1] clamp then
1789
- // bends the hue \u2014 fringe pixels decode to colours that exist nowhere
1790
- // in the block (and the bbox constraint also measures better in plain
1791
- // SSE, +1.8 dB on the colour test card).
1777
+ // Refit result clamped to the block bbox: on multi-cluster blocks the
1778
+ // unconstrained solve extrapolates outside the block's colours and the
1779
+ // per-channel [0,1] clamp would bend the hue.
1792
1780
  var e0 = lo;
1793
1781
  var e1 = hi;
1794
1782
  var fitStream = 0u;
1795
1783
  var haveFitWeights = false;
1796
- if (opaque) {
1797
- // Bbox-clamped like the fit output: on multi-cluster blocks the axis
1798
- // extents overshoot the data per-channel and decode to colours that
1799
- // exist nowhere in the block (the odd-size padding gate caught a
1800
- // \u22123.9 dB crop without this).
1801
- e0 = clamp(seed_lo, lo, hi);
1802
- e1 = clamp(seed_hi, lo, hi);
1803
- } else {
1804
- let r = proj_fit(&pix, seed_lo, seed_hi);
1805
- if (r.valid) {
1806
- e0 = clamp(r.e0, lo, hi);
1807
- e1 = clamp(r.e1, lo, hi);
1808
- fitStream = r.wstream;
1809
- haveFitWeights = true;
1810
- }
1784
+ let r = proj_fit(&pix, seed_lo, seed_hi);
1785
+ if (r.valid) {
1786
+ e0 = clamp(r.e0, lo, hi);
1787
+ e1 = clamp(r.e1, lo, hi);
1788
+ fitStream = r.wstream;
1789
+ haveFitWeights = true;
1811
1790
  }
1812
1791
  var E0 = q8(e0);
1813
1792
  var E1 = q8(e1);
1814
-
1815
- // Blue-contraction ordering, applied before the weight pass so weights
1816
- // are already oriented (no reflection needed).
1817
1793
  var swapped = false;
1818
1794
  if (E0.x + E0.y + E0.z > E1.x + E1.y + E1.z) {
1819
1795
  let t = E0; E0 = E1; E1 = t;
1820
1796
  swapped = true;
1821
1797
  }
1822
- let d0 = h4(vec4<f32>(E0)) * h(1.0 / 255.0);
1823
- let d1 = h4(vec4<f32>(E1)) * h(1.0 / 255.0);
1824
-
1825
- // Weight pass against the decoded endpoints. Same \xD732 pre-scale as
1826
- // proj_fit; distinct 8-bit endpoints are \u22651/255 apart (dd\u2083\u2082 \u2265 0.0157),
1827
- // so the flat threshold only catches identical ones.
1828
- let dir = (d1 - d0) * h(32.0);
1829
- let dd = dot(dir, dir);
1830
- if (opaque) {
1831
- // CEM 8: 3-bit weights, stream bit q = 3k.
1832
- var s0 = 0u; var s1 = 0u;
1833
- if (dd >= h(0.008)) {
1834
- let inv = h(224.0) / dd;
1835
- for (var k: u32 = 0u; k < 16u; k = k + 1u) {
1836
- let w = u32(clamp(floor(dot(pix[k] - d0, dir) * inv + h(0.5)), h(0.0), h(7.0)));
1837
- let off = 3u * k;
1838
- if (off < 30u) { s0 = s0 | (w << off); }
1839
- else if (off == 30u) { s0 = s0 | (w << 30u); s1 = s1 | (w >> 2u); }
1840
- else { s1 = s1 | (w << (off - 32u)); }
1841
- }
1842
- }
1843
- // Mode 0x053, CEM 8 @13, endpoints R0 R1 G0 G1 B0 B1 from bit 17.
1844
- w0 = 0x053u | (8u << 13u) | (E0.x << 17u) | (E1.x << 25u);
1845
- w1 = (E1.x >> 7u) | (E0.y << 1u) | (E1.y << 9u) | (E0.z << 17u) | (E1.z << 25u);
1846
- w2 = (E1.z >> 7u) | reverseBits(s1);
1847
- w3 = reverseBits(s0);
1798
+ // Valid fits ship the FIT-PASS weights instead of reprojecting (a whole
1799
+ // 16-pixel pass for \u22120.09 dB on the alpha card); the swap is a full
1800
+ // reflection w \u2192 3 \u2212 w, i.e. bitwise NOT of the stream. Degenerate fits
1801
+ // reproject against the bbox endpoints.
1802
+ var s0 = 0u;
1803
+ if (haveFitWeights) {
1804
+ s0 = select(fitStream, ~fitStream, swapped);
1848
1805
  } else {
1849
- // CEM 12: 2-bit weights, stream bit q = 2k (single stream word).
1850
- // Valid fits ship the FIT-PASS weights instead of reprojecting \u2014
1851
- // worth a whole 16-pixel pass for \u22120.09 dB on the alpha card (/ab +
1852
- // PSNR A/B, 2026-07; the pre-adaptive-CEM encoder rejected this same
1853
- // trade when EVERY block was CEM 12 \u2014 now only translucent blocks
1854
- // pay it). The blue-contraction swap is a full reflection w \u2192 3\u2212w,
1855
- // i.e. bitwise NOT of the packed stream. Invalid fits (rank-1 /
1856
- // degenerate) fall back to reprojection against the bbox endpoints.
1857
- var s0 = 0u;
1858
- if (haveFitWeights) {
1859
- s0 = select(fitStream, ~fitStream, swapped);
1860
- } else if (dd >= h(0.008)) {
1806
+ let d0 = h4(vec4<f32>(E0)) * h(1.0 / 255.0);
1807
+ let d1 = h4(vec4<f32>(E1)) * h(1.0 / 255.0);
1808
+ let dir = (d1 - d0) * h(32.0);
1809
+ let dd = dot(dir, dir);
1810
+ if (dd >= h(0.008)) {
1861
1811
  let inv = h(96.0) / dd;
1862
1812
  for (var k: u32 = 0u; k < 16u; k = k + 1u) {
1863
1813
  let w = u32(clamp(floor(dot(pix[k] - d0, dir) * inv + h(0.5)), h(0.0), h(3.0)));
1864
1814
  s0 = s0 | (w << (2u * k));
1865
1815
  }
1866
1816
  }
1867
- // Mode 0x042, CEM 12 @13, endpoints R0 R1 G0 G1 B0 B1 A0 A1 from 17.
1868
- w0 = 0x042u | (12u << 13u) | (E0.x << 17u) | (E1.x << 25u);
1869
- w1 = (E1.x >> 7u) | (E0.y << 1u) | (E1.y << 9u) | (E0.z << 17u) | (E1.z << 25u);
1870
- w2 = (E1.z >> 7u) | (E0.w << 1u) | (E1.w << 9u);
1871
- w3 = reverseBits(s0);
1872
1817
  }
1818
+ // Mode 0x042, CEM 12 @13, endpoints R0 R1 G0 G1 B0 B1 A0 A1 from 17.
1819
+ w0 = 0x042u | (12u << 13u) | (E0.x << 17u) | (E1.x << 25u);
1820
+ w1 = (E1.x >> 7u) | (E0.y << 1u) | (E1.y << 9u) | (E0.z << 17u) | (E1.z << 25u);
1821
+ w2 = (E1.z >> 7u) | (E0.w << 1u) | (E1.w << 9u);
1822
+ w3 = reverseBits(s0);
1873
1823
  }
1874
1824
 
1875
1825
  let o = bi * 4u;
@@ -1902,10 +1852,10 @@ var ASTC4x4Encoder = class extends Encoder {
1902
1852
  };
1903
1853
 
1904
1854
  // src/etc2.wgsl
1905
- var etc2_default = "// ETC2 RGB8 compute shader encoder.\n//\n// Each invocation encodes one 4x4 pixel block into an 8-byte ETC2 RGB8 block\n// written as 2 x u32 into the destination storage buffer. ETC2 blocks are\n// big-endian on the wire (byte 0 = bits 63..56), so both words are byte-\n// swapped on the way out. The f16 module (etc2_fast_f16.wgsl) is an\n// EXACT-VALUE port \u2014 byte-identical output; see its header.\n//\n// ALGORITHM \u2014 scalar-luma selection (2026-07 rewrite; the original\n// brute-force 8-table \xD7 4-modifier \xD7 vec3-with-clamp search measured\n// 6.0 ms @2048\xB2 on Apple/metal-3, this one ~0.197 ms with the DRAM read\n// floor \u2014 16 loads + store, nothing else \u2014 at ~0.15). This is the SETTLED\n// speed/quality point: the two-candidate scored search below was once\n// swapped for an O(1) hedged pick (\u22124-7% GPU) but cost \u22120.5 dB average \u2014\n// a ~10\xD7 worse dB-per-percent trade than the refit drop \u2014 and was\n// restored. A two-pass prepared-source variant (encode pass 0.115 ms) is\n// in git history: its prep pass is also DRAM-bound and cannot overlap,\n// so the per-texture total regressed. Reading the full RGBA8 source once\n// is this machine's hard floor for any single-pass encoder; the ~0.045\n// above it is the whole algorithm.\n//\n// \u2022 The ETC1 modifier is a SCALAR shift along (1,1,1), so per texel\n// err(m) = ||e||\xB2 \u2212 2mD + 3m\xB2 with D = luma(p) \u2212 luma(base), where\n// luma(x) = x.r+x.g+x.b. Selection therefore needs only |D| threshold\n// tests: the best table entry is the m with 3m nearest D (A3/B3/THR\n// below), and \u03A3||e||\xB2 per subblock is O(1) from the load loop's\n// quadrant sums (\u03A3||p||\xB2 \u2212 2\xB7base\xB7\u03A3p + 8\xB7||base||\xB2). This estimate is\n// EXACT for unclamped decode and an UPPER BOUND on the true clamped\n// error (clamping toward [0,255] can only shrink per-channel error),\n// so every est-based gate is conservative.\n// \u2022 Flip preselect, O(1): per subblock the residual after PERFECT\n// continuous luma modulation is within-variance \u2212 (luma variance)/3;\n// the flip with the smaller summed residual wins and only it is\n// searched (both-flip est search measured +23% GPU for \u22640.15 dB).\n// Exact-grayscale blocks have BOTH residuals identically zero (all\n// variance is along luma), so near-ties fall back to scoring both\n// flips \u2014 without that, roughness/AO-style content loses ~1.25 dB.\n// \u2022 Table search is pruned to two candidates \u2014 the table whose LARGE\n// magnitude covers max|D| and its lower neighbour (outlier hedge).\n// One candidate loses ~1.2-1.6 dB on photos; all eight gain \u22640.05 dB.\n// \u2022 NO base refit. The refit family (base \u2190 subblock mean \u2212 mean chosen\n// modifier) was worth ~0.2 dB on photographic colour (rock-color\n// 33.98 \u2192 33.79 without it) but even its cheapest accepted form cost\n// ~13% GPU and the exact-accept original ~30% \u2014 dropped 2026-07 as a\n// deliberate speed/quality trade; see the suite baselines.\n// \u2022 PLANAR runs unconditionally: with the right-hand sides folded into\n// the load loop the LSQ solve is O(1) (the Gram inverse of the fixed\n// sample positions is a constant, det = 25) and its residual is the\n// closed-form \u03A3||p||\xB2 \u2212 2\xB7\u03B8\xB7rhs + \u03B8\u1D40G\u03B8 evaluated with the QUANTISED,\n// clamped corners \u2014 exact up to decode's floor-rounding, and crucially\n// clamp-aware (a continuous-corner estimate mis-picks planar on steep\n// gradients). Gating planar cost \u22120.31 dB on smooth content for zero\n// measured speed.\n// \u2022 T and H modes are decoded by hardware but never emitted \u2014 their win\n// is limited to two-chroma-cluster blocks (the colour card's per-pixel\n// chroma checkers are the visible gap) and needs a clustering pass.\n//\n// Numeric notes: texel loads use round(load\xB7255) (integer-exact unorm trip);\n// every m3 in A3/B3 is divisible by 3 so m = m3/3 is exact; est values are\n// integer sums held exactly in f32 (< 2^24).\n\nstruct Params {\n blocks_x: u32,\n blocks_y: u32,\n width: u32,\n height: u32,\n};\n\n@group(0) @binding(0) var src_tex: texture_2d<f32>;\n@group(0) @binding(1) var<storage, read_write> dst: array<u32>;\n@group(0) @binding(2) var<uniform> params: Params;\n\nconst A3 = array<f32, 8>(6.0, 15.0, 27.0, 39.0, 54.0, 72.0, 99.0, 141.0);\nconst B3 = array<f32, 8>(24.0, 51.0, 87.0, 126.0, 180.0, 240.0, 318.0, 549.0);\nconst THR = array<f32, 8>(15.0, 33.0, 57.0, 82.5, 117.0, 156.0, 208.5, 345.0);\n\n// Planar's closed-form estimate models the QUANTISED corners exactly; only\n// decode's floor-rounding (\xB1\xBD per sample) is unmodelled. This small bias\n// keeps near-ties on the predictable ETC1 side.\nconst PLANAR_FUDGE = 8.0;\n\nfn texel_of(flip: u32, sb: u32, i: u32) -> u32 {\n if (flip == 0u) {\n return (i >> 1u) * 4u + sb * 2u + (i & 1u);\n }\n return (sb * 2u + (i >> 2u)) * 4u + (i & 3u);\n}\n\nfn quant_codes(v: vec3<f32>, max_code: vec3<f32>) -> vec3<u32> {\n return vec3<u32>(clamp(floor(v * max_code * (1.0 / 255.0) + 0.5), vec3<f32>(0.0), max_code));\n}\n\nfn extend4(c: vec3<u32>) -> vec3<f32> {\n return vec3<f32>((c << vec3<u32>(4u)) | c);\n}\nfn extend5(c: vec3<u32>) -> vec3<f32> {\n return vec3<f32>((c << vec3<u32>(3u)) | (c >> vec3<u32>(2u)));\n}\n\nfn signed3(bits: u32) -> i32 {\n return select(i32(bits), i32(bits) - 8, bits > 3u);\n}\n\nfn bswap(x: u32) -> u32 {\n return ((x & 0xffu) << 24u) | ((x & 0xff00u) << 8u) | ((x >> 8u) & 0xff00u) | (x >> 24u);\n}\n\nstruct BasePair {\n codes0: vec3<u32>,\n codes1: vec3<u32>,\n ok: bool,\n};\nfn quantise_bases(avg0: vec3<f32>, avg1: vec3<f32>, diff: bool, clamp_delta: bool) -> BasePair {\n var out: BasePair;\n out.ok = true;\n if (!diff) {\n out.codes0 = quant_codes(avg0, vec3<f32>(15.0));\n out.codes1 = quant_codes(avg1, vec3<f32>(15.0));\n return out;\n }\n let q0 = vec3<i32>(quant_codes(avg0, vec3<f32>(31.0)));\n let q1 = vec3<i32>(quant_codes(avg1, vec3<f32>(31.0)));\n let d = q1 - q0;\n if (any(d < vec3<i32>(-4)) || any(d > vec3<i32>(3))) {\n if (!clamp_delta) {\n out.ok = false;\n return out;\n }\n }\n out.codes0 = vec3<u32>(q0);\n out.codes1 = vec3<u32>(q0 + clamp(d, vec3<i32>(-4), vec3<i32>(3)));\n return out;\n}\n\nstruct SearchOut {\n table: u32,\n acc: f32,\n};\nfn sb_table_score(luma: ptr<function, array<f32, 16>>, flip: u32, sb: u32, lb: f32, t: u32) -> f32 {\n let a3 = A3[t];\n let b3 = B3[t];\n let thr = THR[t];\n var acc = 0.0;\n for (var i: u32 = 0u; i < 8u; i = i + 1u) {\n let ad = abs((*luma)[texel_of(flip, sb, i)] - lb);\n let m3 = select(a3, b3, ad > thr);\n acc = acc + m3 * (m3 - 2.0 * ad);\n }\n return acc;\n}\nfn sb_search(luma: ptr<function, array<f32, 16>>, flip: u32, sb: u32, lb: f32) -> SearchOut {\n var mx = 0.0;\n for (var i: u32 = 0u; i < 8u; i = i + 1u) {\n mx = max(mx, abs((*luma)[texel_of(flip, sb, i)] - lb));\n }\n let cover = min(\n u32(mx > 24.0) + u32(mx > 51.0) + u32(mx > 87.0) + u32(mx > 126.0) +\n u32(mx > 180.0) + u32(mx > 240.0) + u32(mx > 318.0),\n 7u,\n );\n let t_lo = select(cover - 1u, 0u, cover == 0u);\n let acc_lo = sb_table_score(luma, flip, sb, lb, t_lo);\n let acc_hi = sb_table_score(luma, flip, sb, lb, cover);\n var out: SearchOut;\n let lo_wins = acc_lo <= acc_hi;\n out.table = select(cover, t_lo, lo_wins);\n out.acc = select(acc_hi, acc_lo, lo_wins);\n return out;\n}\n\n// One flip's base quantisation + table search: everything the flip contest\n// and the index derivation need.\nstruct FlipFit {\n est: f32,\n diff: bool,\n bases: BasePair,\n lb0: f32,\n lb1: f32,\n t0: u32,\n t1: u32,\n};\nfn eval_flip(\n luma: ptr<function, array<f32, 16>>,\n flip: u32,\n sum0: vec3<f32>,\n sq0: f32,\n sum1: vec3<f32>,\n sq1: f32,\n) -> FlipFit {\n let avg0 = sum0 * 0.125;\n let avg1 = sum1 * 0.125;\n let try_diff = quantise_bases(avg0, avg1, true, false);\n var out: FlipFit;\n out.diff = try_diff.ok;\n if (out.diff) {\n out.bases = try_diff;\n } else {\n out.bases = quantise_bases(avg0, avg1, false, false);\n }\n var b0: vec3<f32>;\n var b1: vec3<f32>;\n if (out.diff) {\n b0 = extend5(out.bases.codes0);\n b1 = extend5(out.bases.codes1);\n } else {\n b0 = extend4(out.bases.codes0);\n b1 = extend4(out.bases.codes1);\n }\n out.lb0 = b0.r + b0.g + b0.b;\n out.lb1 = b1.r + b1.g + b1.b;\n let s0 = sb_search(luma, flip, 0u, out.lb0);\n let s1 = sb_search(luma, flip, 1u, out.lb1);\n out.t0 = s0.table;\n out.t1 = s1.table;\n out.est = (sq0 - 2.0 * dot(b0, sum0) + 8.0 * dot(b0, b0)) +\n (sq1 - 2.0 * dot(b1, sum1) + 8.0 * dot(b1, b1)) +\n (s0.acc + s1.acc) * (1.0 / 3.0);\n return out;\n}\n\n// Wire indices for a chosen table \u2014 computed ONCE, from the final base.\nfn sb_indices(luma: ptr<function, array<f32, 16>>, flip: u32, sb: u32, lb: f32, t: u32) -> u32 {\n let thr = THR[t];\n var indices = 0u;\n for (var i: u32 = 0u; i < 8u; i = i + 1u) {\n let d = (*luma)[texel_of(flip, sb, i)] - lb;\n let large = abs(d) > thr;\n let neg = d < 0.0;\n indices = indices | ((select(0u, 1u, large) | select(0u, 2u, neg)) << (i * 2u));\n }\n return indices;\n}\n\n@compute @workgroup_size(8, 8, 1)\nfn encode(@builtin(global_invocation_id) gid: vec3<u32>) {\n if (gid.x >= params.blocks_x || gid.y >= params.blocks_y) {\n return;\n }\n\n let block_index = gid.y * params.blocks_x + gid.x;\n let base_xy = vec2<i32>(i32(gid.x) * 4, i32(gid.y) * 4);\n let max_xy = vec2<i32>(i32(params.width) - 1, i32(params.height) - 1);\n\n var luma: array<f32, 16>;\n var qsum: array<vec3<f32>, 4>;\n var qsq: array<f32, 4>;\n var qlsq: array<f32, 4>;\n // Planar right-hand sides, folded into the load: rB = \u03A3 (x/4)\xB7p and\n // rC = \u03A3 (y/4)\xB7p accumulate unscaled; rA = \u03A3p \u2212 rB \u2212 rC afterwards.\n var sxp = vec3<f32>(0.0);\n var syp = vec3<f32>(0.0);\n\n for (var i: u32 = 0u; i < 16u; i = i + 1u) {\n let lx = i32(i & 3u);\n let ly = i32(i >> 2u);\n let p = clamp(base_xy + vec2<i32>(lx, ly), vec2<i32>(0, 0), max_xy);\n let c = round(textureLoad(src_tex, p, 0).rgb * 255.0);\n let l = c.r + c.g + c.b;\n luma[i] = l;\n let q = u32(lx >= 2) | (u32(ly >= 2) << 1u);\n qsum[q] = qsum[q] + c;\n qsq[q] = qsq[q] + dot(c, c);\n qlsq[q] = qlsq[q] + l * l;\n sxp = sxp + f32(lx) * c;\n syp = syp + f32(ly) * c;\n }\n\n // ----------------------------------------------- flip + base selection --\n // Flip preselect, O(1) from quadrant sums: per subblock the residual after\n // PERFECT continuous luma modulation is (\u03A3||p||\xB2 \u2212 ||\u03A3p||\xB2/8) \u2212\n // (\u03A3\u2113\xB2 \u2212 (\u03A3\u2113)\xB2/8)/3 \u2014 the within-variance minus the (1,1,1)-direction\n // component the modifier tables can absorb. The flip minimising the summed\n // residual wins and only it gets the table search \u2014 EXCEPT when the two\n // residuals are indistinguishable: for exact-grayscale blocks (r=g=b) both\n // are identically zero, so the contest falls back to scoring both flips\n // (this recovered \u22121.25 dB on roughness/AO-style content).\n let sum0a = qsum[0] + qsum[2];\n let sum1a = qsum[1] + qsum[3];\n let sq0a = qsq[0] + qsq[2];\n let sq1a = qsq[1] + qsq[3];\n let sum0b = qsum[0] + qsum[1];\n let sum1b = qsum[2] + qsum[3];\n let sq0b = qsq[0] + qsq[1];\n let sq1b = qsq[2] + qsq[3];\n let lsq0a = qlsq[0] + qlsq[2];\n let lsq1a = qlsq[1] + qlsq[3];\n let lsq0b = qlsq[0] + qlsq[1];\n let lsq1b = qlsq[2] + qlsq[3];\n let res_a = (sq0a - dot(sum0a, sum0a) * 0.125) - (lsq0a - dot(sum0a, vec3<f32>(1.0)) * dot(sum0a, vec3<f32>(1.0)) * 0.125) * (1.0 / 3.0)\n + (sq1a - dot(sum1a, sum1a) * 0.125) - (lsq1a - dot(sum1a, vec3<f32>(1.0)) * dot(sum1a, vec3<f32>(1.0)) * 0.125) * (1.0 / 3.0);\n let res_b = (sq0b - dot(sum0b, sum0b) * 0.125) - (lsq0b - dot(sum0b, vec3<f32>(1.0)) * dot(sum0b, vec3<f32>(1.0)) * 0.125) * (1.0 / 3.0)\n + (sq1b - dot(sum1b, sum1b) * 0.125) - (lsq1b - dot(sum1b, vec3<f32>(1.0)) * dot(sum1b, vec3<f32>(1.0)) * 0.125) * (1.0 / 3.0);\n\n // Single eval_flip call site (a second inlined copy measured +50% GPU):\n // attempt 0 scores the primary flip, attempt 1 runs only in the dual\n // (indistinguishable-residuals) case and scores the other flip.\n let dual = abs(res_a - res_b) < 1.0;\n let primary = select(select(0u, 1u, res_b < res_a), 0u, dual);\n var bflip = primary;\n var sel: FlipFit;\n for (var attempt = 0u; attempt < 2u; attempt = attempt + 1u) {\n if (attempt == 1u && !dual) {\n break;\n }\n let f = select(primary, 1u, attempt == 1u);\n let cand = eval_flip(\n &luma,\n f,\n select(sum0a, sum0b, f == 1u),\n select(sq0a, sq0b, f == 1u),\n select(sum1a, sum1b, f == 1u),\n select(sq1a, sq1b, f == 1u),\n );\n if (attempt == 0u || cand.est < sel.est) {\n sel = cand;\n bflip = f;\n }\n }\n let bdiff = sel.diff;\n\n let best_est = sel.est;\n let codes0 = sel.bases.codes0;\n let codes1 = sel.bases.codes1;\n let t0 = sel.t0;\n let t1 = sel.t1;\n let fit0 = sb_indices(&luma, bflip, 0u, sel.lb0, t0);\n let fit1 = sb_indices(&luma, bflip, 1u, sel.lb1, t1);\n\n // ------------------------------------------------------------ planar --\n // Always evaluated: with the rhs folded into the load loop this is O(1),\n // and gating it on the ETC1 estimate measured \u22120.31 dB on smooth content\n // for zero speed.\n let total = qsum[0] + qsum[1] + qsum[2] + qsum[3];\n let sqtotal = qsq[0] + qsq[1] + qsq[2] + qsq[3];\n let rB = sxp * 0.25;\n let rC = syp * 0.25;\n let rA = total - rB - rC;\n let po = 0.2875 * rA - 0.0125 * rB - 0.0125 * rC;\n let ph = -0.0125 * rA + 0.4875 * rB - 0.3125 * rC;\n let pv = -0.0125 * rA - 0.3125 * rB + 0.4875 * rC;\n let pmax = vec3<f32>(63.0, 127.0, 63.0);\n let qo = quant_codes(po, pmax);\n let qh = quant_codes(ph, pmax);\n let qv = quant_codes(pv, pmax);\n // Residual of the plane the hardware will ACTUALLY decode \u2014 the\n // quantised, clamped corners \u2014 via the normal-equation identity\n // \u03A3||p \u2212 f||\xB2 = \u03A3||p||\xB2 \u2212 2\xB7\u03B8\xB7rhs + \u03B8\u1D40G\u03B8 (G is the constant Gram matrix\n // of the fixed sample positions). Estimating with the CONTINUOUS corners\n // instead is blind to corner clamping and mis-picks planar on steep\n // gradients (a 1.4-normalised-SSE easy-block artifact on the colour\n // card). Only decode's floor-rounding stays unmodelled (\u2264 ~12 SSE).\n let shl = vec3<u32>(2u, 1u, 2u);\n let shr = vec3<u32>(4u, 6u, 4u);\n let eo = vec3<f32>((qo << shl) | (qo >> shr));\n let eh = vec3<f32>((qh << shl) | (qh >> shr));\n let ev = vec3<f32>((qv << shl) | (qv >> shr));\n let gram = 3.5 * (eo * eo + eh * eh + ev * ev) + 0.5 * eo * eh + 0.5 * eo * ev + 4.5 * eh * ev;\n let planar_est = sqtotal - 2.0 * (dot(eo, rA) + dot(eh, rB) + dot(ev, rC)) +\n dot(gram, vec3<f32>(1.0)) + PLANAR_FUDGE;\n\n // ------------------------------------------------------------ packing --\n var hi: u32;\n var lo: u32;\n if (best_est <= planar_est) {\n if (bdiff) {\n let d = vec3<u32>(vec3<i32>(codes1) - vec3<i32>(codes0)) & vec3<u32>(7u);\n hi = (codes0.r << 27u) | (d.r << 24u) | (codes0.g << 19u) | (d.g << 16u) | (codes0.b << 11u) | (d.b << 8u)\n | (t0 << 5u) | (t1 << 2u) | 2u | bflip;\n } else {\n hi = (codes0.r << 28u) | (codes1.r << 24u) | (codes0.g << 20u) | (codes1.g << 16u) | (codes0.b << 12u) | (codes1.b << 8u)\n | (t0 << 5u) | (t1 << 2u) | bflip;\n }\n lo = 0u;\n for (var sb: u32 = 0u; sb < 2u; sb = sb + 1u) {\n let indices = select(fit0, fit1, sb == 1u);\n for (var i: u32 = 0u; i < 8u; i = i + 1u) {\n let k = texel_of(bflip, sb, i);\n let wire = (k & 3u) * 4u + (k >> 2u);\n let idx = (indices >> (i * 2u)) & 3u;\n lo = lo | ((idx & 1u) << wire) | ((idx >> 1u) << (16u + wire));\n }\n }\n } else {\n let ro = qo.r; let go = qo.g; let bo = qo.b;\n let rh = qh.r; let gh = qh.g; let bh = qh.b;\n let rv = qv.r; let gv = qv.g; let bv = qv.b;\n let r_sum = i32(ro >> 2u) + signed3(((ro & 3u) << 1u) | (go >> 6u));\n let r_fix = select(0u, 1u, r_sum < 0);\n let g_sum = i32((go >> 2u) & 15u) + signed3(((go & 3u) << 1u) | (bo >> 5u));\n let g_fix = select(0u, 1u, g_sum < 0);\n let p = (bo >> 3u) & 3u;\n let q = (bo >> 1u) & 3u;\n let b_fix3 = select(0u, 7u, p + q >= 4u);\n let b_fix1 = select(1u, 0u, p + q >= 4u);\n hi = (r_fix << 31u) | (ro << 25u) | ((go >> 6u) << 24u) | (g_fix << 23u) | ((go & 63u) << 17u)\n | ((bo >> 5u) << 16u) | (b_fix3 << 13u) | (((bo >> 3u) & 3u) << 11u) | (b_fix1 << 10u)\n | ((bo & 7u) << 7u) | ((rh >> 1u) << 2u) | 2u | (rh & 1u);\n lo = (gh << 25u) | (bh << 19u) | (rv << 13u) | (gv << 6u) | bv;\n }\n\n let out = block_index * 2u;\n dst[out] = bswap(hi);\n dst[out + 1u] = bswap(lo);\n}\n";
1855
+ var etc2_default = "// ETC2 RGB8 compute shader encoder.\n//\n// Each invocation encodes one 4x4 pixel block into an 8-byte ETC2 RGB8 block\n// written as 2 x u32 into the destination storage buffer. ETC2 blocks are\n// big-endian on the wire (byte 0 = bits 63..56), so both words are byte-\n// swapped on the way out. The f16 module (etc2_fast_f16.wgsl) is an\n// EXACT-VALUE port \u2014 byte-identical output; see its header.\n//\n// ALGORITHM \u2014 scalar-luma selection (2026-07 rewrite; the original\n// brute-force 8-table \xD7 4-modifier \xD7 vec3-with-clamp search measured\n// 6.0 ms @2048\xB2 on Apple/metal-3, this one ~0.197 ms with the DRAM read\n// floor \u2014 16 loads + store, nothing else \u2014 at ~0.15). This is the SETTLED\n// speed/quality point: the two-candidate scored search below was once\n// swapped for an O(1) hedged pick (\u22124-7% GPU) but cost \u22120.5 dB average \u2014\n// a ~10\xD7 worse dB-per-percent trade than the refit drop \u2014 and was\n// restored. A two-pass prepared-source variant (encode pass 0.115 ms) is\n// in git history: its prep pass is also DRAM-bound and cannot overlap,\n// so the per-texture total regressed. Reading the full RGBA8 source once\n// is this machine's hard floor for any single-pass encoder; the ~0.045\n// above it is the whole algorithm.\n//\n// \u2022 The ETC1 modifier is a SCALAR shift along (1,1,1), so per texel\n// err(m) = ||e||\xB2 \u2212 2mD + 3m\xB2 with D = luma(p) \u2212 luma(base), where\n// luma(x) = x.r+x.g+x.b. Selection therefore needs only |D| threshold\n// tests: the best table entry is the m with 3m nearest D (A3/B3/THR\n// below), and \u03A3||e||\xB2 per subblock is O(1) from the load loop's\n// quadrant sums (\u03A3||p||\xB2 \u2212 2\xB7base\xB7\u03A3p + 8\xB7||base||\xB2). This estimate is\n// EXACT for unclamped decode and an UPPER BOUND on the true clamped\n// error (clamping toward [0,255] can only shrink per-channel error),\n// so every est-based gate is conservative.\n// \u2022 Flip preselect, O(1): per subblock the residual after PERFECT\n// continuous luma modulation is within-variance \u2212 (luma variance)/3;\n// the flip with the smaller summed residual wins and only it is\n// searched (both-flip est search measured +23% GPU for \u22640.15 dB).\n// Exact-grayscale blocks have BOTH residuals identically zero (all\n// variance is along luma), so near-ties fall back to scoring both\n// flips \u2014 without that, roughness/AO-style content loses ~1.25 dB.\n// \u2022 Table search is pruned to two candidates \u2014 the table whose LARGE\n// magnitude covers max|D| and its lower neighbour (outlier hedge).\n// One candidate loses ~1.2-1.6 dB on photos; all eight gain \u22640.05 dB.\n// \u2022 NO base refit. The refit family (base \u2190 subblock mean \u2212 mean chosen\n// modifier) was worth ~0.2 dB on photographic colour (rock-color\n// 33.98 \u2192 33.79 without it) but even its cheapest accepted form cost\n// ~13% GPU and the exact-accept original ~30% \u2014 dropped 2026-07 as a\n// deliberate speed/quality trade; see the suite baselines.\n// \u2022 PLANAR runs unconditionally: with the right-hand sides folded into\n// the load loop the LSQ solve is O(1) (the Gram inverse of the fixed\n// sample positions is a constant, det = 25) and its residual is the\n// closed-form \u03A3||p||\xB2 \u2212 2\xB7\u03B8\xB7rhs + \u03B8\u1D40G\u03B8 evaluated with the QUANTISED,\n// clamped corners \u2014 exact up to decode's floor-rounding, and crucially\n// clamp-aware (a continuous-corner estimate mis-picks planar on steep\n// gradients). Gating planar cost \u22120.31 dB on smooth content for zero\n// measured speed.\n// \u2022 T and H modes are decoded by hardware but never emitted \u2014 their win\n// is limited to two-chroma-cluster blocks (the colour card's per-pixel\n// chroma checkers are the visible gap) and needs a clustering pass.\n//\n// Numeric notes: texel loads use round(load\xB7255) (integer-exact unorm trip);\n// every m3 in A3/B3 is divisible by 3 so m = m3/3 is exact; est values are\n// integer sums held exactly in f32 (< 2^24).\n\nstruct Params {\n blocks_x: u32,\n blocks_y: u32,\n width: u32,\n height: u32,\n y0: u32, // first block row of this dispatch (row-band encodes)\n};\n\n@group(0) @binding(0) var src_tex: texture_2d<f32>;\n@group(0) @binding(1) var<storage, read_write> dst: array<u32>;\n@group(0) @binding(2) var<uniform> params: Params;\n\nconst A3 = array<f32, 8>(6.0, 15.0, 27.0, 39.0, 54.0, 72.0, 99.0, 141.0);\nconst B3 = array<f32, 8>(24.0, 51.0, 87.0, 126.0, 180.0, 240.0, 318.0, 549.0);\nconst THR = array<f32, 8>(15.0, 33.0, 57.0, 82.5, 117.0, 156.0, 208.5, 345.0);\n\n// Planar's closed-form estimate models the QUANTISED corners exactly; only\n// decode's floor-rounding (\xB1\xBD per sample) is unmodelled. This small bias\n// keeps near-ties on the predictable ETC1 side.\nconst PLANAR_FUDGE = 8.0;\n\nfn texel_of(flip: u32, sb: u32, i: u32) -> u32 {\n if (flip == 0u) {\n return (i >> 1u) * 4u + sb * 2u + (i & 1u);\n }\n return (sb * 2u + (i >> 2u)) * 4u + (i & 3u);\n}\n\nfn quant_codes(v: vec3<f32>, max_code: vec3<f32>) -> vec3<u32> {\n return vec3<u32>(clamp(floor(v * max_code * (1.0 / 255.0) + 0.5), vec3<f32>(0.0), max_code));\n}\n\nfn extend4(c: vec3<u32>) -> vec3<f32> {\n return vec3<f32>((c << vec3<u32>(4u)) | c);\n}\nfn extend5(c: vec3<u32>) -> vec3<f32> {\n return vec3<f32>((c << vec3<u32>(3u)) | (c >> vec3<u32>(2u)));\n}\n\nfn signed3(bits: u32) -> i32 {\n return select(i32(bits), i32(bits) - 8, bits > 3u);\n}\n\nfn bswap(x: u32) -> u32 {\n return ((x & 0xffu) << 24u) | ((x & 0xff00u) << 8u) | ((x >> 8u) & 0xff00u) | (x >> 24u);\n}\n\nstruct BasePair {\n codes0: vec3<u32>,\n codes1: vec3<u32>,\n ok: bool,\n};\nfn quantise_bases(avg0: vec3<f32>, avg1: vec3<f32>, diff: bool, clamp_delta: bool) -> BasePair {\n var out: BasePair;\n out.ok = true;\n if (!diff) {\n out.codes0 = quant_codes(avg0, vec3<f32>(15.0));\n out.codes1 = quant_codes(avg1, vec3<f32>(15.0));\n return out;\n }\n let q0 = vec3<i32>(quant_codes(avg0, vec3<f32>(31.0)));\n let q1 = vec3<i32>(quant_codes(avg1, vec3<f32>(31.0)));\n let d = q1 - q0;\n if (any(d < vec3<i32>(-4)) || any(d > vec3<i32>(3))) {\n if (!clamp_delta) {\n out.ok = false;\n return out;\n }\n }\n out.codes0 = vec3<u32>(q0);\n out.codes1 = vec3<u32>(q0 + clamp(d, vec3<i32>(-4), vec3<i32>(3)));\n return out;\n}\n\nstruct SearchOut {\n table: u32,\n acc: f32,\n};\nfn sb_table_score(luma: ptr<function, array<f32, 16>>, flip: u32, sb: u32, lb: f32, t: u32) -> f32 {\n let a3 = A3[t];\n let b3 = B3[t];\n let thr = THR[t];\n var acc = 0.0;\n for (var i: u32 = 0u; i < 8u; i = i + 1u) {\n let ad = abs((*luma)[texel_of(flip, sb, i)] - lb);\n let m3 = select(a3, b3, ad > thr);\n acc = acc + m3 * (m3 - 2.0 * ad);\n }\n return acc;\n}\nfn sb_search(luma: ptr<function, array<f32, 16>>, flip: u32, sb: u32, lb: f32) -> SearchOut {\n var mx = 0.0;\n for (var i: u32 = 0u; i < 8u; i = i + 1u) {\n mx = max(mx, abs((*luma)[texel_of(flip, sb, i)] - lb));\n }\n let cover = min(\n u32(mx > 24.0) + u32(mx > 51.0) + u32(mx > 87.0) + u32(mx > 126.0) +\n u32(mx > 180.0) + u32(mx > 240.0) + u32(mx > 318.0),\n 7u,\n );\n let t_lo = select(cover - 1u, 0u, cover == 0u);\n let acc_lo = sb_table_score(luma, flip, sb, lb, t_lo);\n let acc_hi = sb_table_score(luma, flip, sb, lb, cover);\n var out: SearchOut;\n let lo_wins = acc_lo <= acc_hi;\n out.table = select(cover, t_lo, lo_wins);\n out.acc = select(acc_hi, acc_lo, lo_wins);\n return out;\n}\n\n// One flip's base quantisation + table search: everything the flip contest\n// and the index derivation need.\nstruct FlipFit {\n est: f32,\n diff: bool,\n bases: BasePair,\n lb0: f32,\n lb1: f32,\n t0: u32,\n t1: u32,\n};\nfn eval_flip(\n luma: ptr<function, array<f32, 16>>,\n flip: u32,\n sum0: vec3<f32>,\n sq0: f32,\n sum1: vec3<f32>,\n sq1: f32,\n) -> FlipFit {\n let avg0 = sum0 * 0.125;\n let avg1 = sum1 * 0.125;\n let try_diff = quantise_bases(avg0, avg1, true, false);\n var out: FlipFit;\n out.diff = try_diff.ok;\n if (out.diff) {\n out.bases = try_diff;\n } else {\n out.bases = quantise_bases(avg0, avg1, false, false);\n }\n var b0: vec3<f32>;\n var b1: vec3<f32>;\n if (out.diff) {\n b0 = extend5(out.bases.codes0);\n b1 = extend5(out.bases.codes1);\n } else {\n b0 = extend4(out.bases.codes0);\n b1 = extend4(out.bases.codes1);\n }\n out.lb0 = b0.r + b0.g + b0.b;\n out.lb1 = b1.r + b1.g + b1.b;\n let s0 = sb_search(luma, flip, 0u, out.lb0);\n let s1 = sb_search(luma, flip, 1u, out.lb1);\n out.t0 = s0.table;\n out.t1 = s1.table;\n out.est = (sq0 - 2.0 * dot(b0, sum0) + 8.0 * dot(b0, b0)) +\n (sq1 - 2.0 * dot(b1, sum1) + 8.0 * dot(b1, b1)) +\n (s0.acc + s1.acc) * (1.0 / 3.0);\n return out;\n}\n\n// Wire indices for a chosen table \u2014 computed ONCE, from the final base.\nfn sb_indices(luma: ptr<function, array<f32, 16>>, flip: u32, sb: u32, lb: f32, t: u32) -> u32 {\n let thr = THR[t];\n var indices = 0u;\n for (var i: u32 = 0u; i < 8u; i = i + 1u) {\n let d = (*luma)[texel_of(flip, sb, i)] - lb;\n let large = abs(d) > thr;\n let neg = d < 0.0;\n indices = indices | ((select(0u, 1u, large) | select(0u, 2u, neg)) << (i * 2u));\n }\n return indices;\n}\n\n@compute @workgroup_size(8, 8, 1)\nfn encode(@builtin(global_invocation_id) gid_raw: vec3<u32>) {\n // Row-band encodes dispatch a slice of the block grid starting at row y0.\n let gid = vec3<u32>(gid_raw.x, gid_raw.y + params.y0, gid_raw.z);\n if (gid.x >= params.blocks_x || gid.y >= params.blocks_y) {\n return;\n }\n\n let block_index = gid.y * params.blocks_x + gid.x;\n let base_xy = vec2<i32>(i32(gid.x) * 4, i32(gid.y) * 4);\n let max_xy = vec2<i32>(i32(params.width) - 1, i32(params.height) - 1);\n\n var luma: array<f32, 16>;\n var qsum: array<vec3<f32>, 4>;\n var qsq: array<f32, 4>;\n var qlsq: array<f32, 4>;\n // Planar right-hand sides, folded into the load: rB = \u03A3 (x/4)\xB7p and\n // rC = \u03A3 (y/4)\xB7p accumulate unscaled; rA = \u03A3p \u2212 rB \u2212 rC afterwards.\n var sxp = vec3<f32>(0.0);\n var syp = vec3<f32>(0.0);\n\n for (var i: u32 = 0u; i < 16u; i = i + 1u) {\n let lx = i32(i & 3u);\n let ly = i32(i >> 2u);\n let p = clamp(base_xy + vec2<i32>(lx, ly), vec2<i32>(0, 0), max_xy);\n let c = round(textureLoad(src_tex, p, 0).rgb * 255.0);\n let l = c.r + c.g + c.b;\n luma[i] = l;\n let q = u32(lx >= 2) | (u32(ly >= 2) << 1u);\n qsum[q] = qsum[q] + c;\n qsq[q] = qsq[q] + dot(c, c);\n qlsq[q] = qlsq[q] + l * l;\n sxp = sxp + f32(lx) * c;\n syp = syp + f32(ly) * c;\n }\n\n // ----------------------------------------------- flip + base selection --\n // Flip preselect, O(1) from quadrant sums: per subblock the residual after\n // PERFECT continuous luma modulation is (\u03A3||p||\xB2 \u2212 ||\u03A3p||\xB2/8) \u2212\n // (\u03A3\u2113\xB2 \u2212 (\u03A3\u2113)\xB2/8)/3 \u2014 the within-variance minus the (1,1,1)-direction\n // component the modifier tables can absorb. The flip minimising the summed\n // residual wins and only it gets the table search \u2014 EXCEPT when the two\n // residuals are indistinguishable: for exact-grayscale blocks (r=g=b) both\n // are identically zero, so the contest falls back to scoring both flips\n // (this recovered \u22121.25 dB on roughness/AO-style content).\n let sum0a = qsum[0] + qsum[2];\n let sum1a = qsum[1] + qsum[3];\n let sq0a = qsq[0] + qsq[2];\n let sq1a = qsq[1] + qsq[3];\n let sum0b = qsum[0] + qsum[1];\n let sum1b = qsum[2] + qsum[3];\n let sq0b = qsq[0] + qsq[1];\n let sq1b = qsq[2] + qsq[3];\n let lsq0a = qlsq[0] + qlsq[2];\n let lsq1a = qlsq[1] + qlsq[3];\n let lsq0b = qlsq[0] + qlsq[1];\n let lsq1b = qlsq[2] + qlsq[3];\n let res_a = (sq0a - dot(sum0a, sum0a) * 0.125) - (lsq0a - dot(sum0a, vec3<f32>(1.0)) * dot(sum0a, vec3<f32>(1.0)) * 0.125) * (1.0 / 3.0)\n + (sq1a - dot(sum1a, sum1a) * 0.125) - (lsq1a - dot(sum1a, vec3<f32>(1.0)) * dot(sum1a, vec3<f32>(1.0)) * 0.125) * (1.0 / 3.0);\n let res_b = (sq0b - dot(sum0b, sum0b) * 0.125) - (lsq0b - dot(sum0b, vec3<f32>(1.0)) * dot(sum0b, vec3<f32>(1.0)) * 0.125) * (1.0 / 3.0)\n + (sq1b - dot(sum1b, sum1b) * 0.125) - (lsq1b - dot(sum1b, vec3<f32>(1.0)) * dot(sum1b, vec3<f32>(1.0)) * 0.125) * (1.0 / 3.0);\n\n // Single eval_flip call site (a second inlined copy measured +50% GPU):\n // attempt 0 scores the primary flip, attempt 1 runs only in the dual\n // (indistinguishable-residuals) case and scores the other flip.\n let dual = abs(res_a - res_b) < 1.0;\n let primary = select(select(0u, 1u, res_b < res_a), 0u, dual);\n var bflip = primary;\n var sel: FlipFit;\n for (var attempt = 0u; attempt < 2u; attempt = attempt + 1u) {\n if (attempt == 1u && !dual) {\n break;\n }\n let f = select(primary, 1u, attempt == 1u);\n let cand = eval_flip(\n &luma,\n f,\n select(sum0a, sum0b, f == 1u),\n select(sq0a, sq0b, f == 1u),\n select(sum1a, sum1b, f == 1u),\n select(sq1a, sq1b, f == 1u),\n );\n if (attempt == 0u || cand.est < sel.est) {\n sel = cand;\n bflip = f;\n }\n }\n let bdiff = sel.diff;\n\n let best_est = sel.est;\n let codes0 = sel.bases.codes0;\n let codes1 = sel.bases.codes1;\n let t0 = sel.t0;\n let t1 = sel.t1;\n let fit0 = sb_indices(&luma, bflip, 0u, sel.lb0, t0);\n let fit1 = sb_indices(&luma, bflip, 1u, sel.lb1, t1);\n\n // ------------------------------------------------------------ planar --\n // Always evaluated: with the rhs folded into the load loop this is O(1),\n // and gating it on the ETC1 estimate measured \u22120.31 dB on smooth content\n // for zero speed.\n let total = qsum[0] + qsum[1] + qsum[2] + qsum[3];\n let sqtotal = qsq[0] + qsq[1] + qsq[2] + qsq[3];\n let rB = sxp * 0.25;\n let rC = syp * 0.25;\n let rA = total - rB - rC;\n let po = 0.2875 * rA - 0.0125 * rB - 0.0125 * rC;\n let ph = -0.0125 * rA + 0.4875 * rB - 0.3125 * rC;\n let pv = -0.0125 * rA - 0.3125 * rB + 0.4875 * rC;\n let pmax = vec3<f32>(63.0, 127.0, 63.0);\n let qo = quant_codes(po, pmax);\n let qh = quant_codes(ph, pmax);\n let qv = quant_codes(pv, pmax);\n // Residual of the plane the hardware will ACTUALLY decode \u2014 the\n // quantised, clamped corners \u2014 via the normal-equation identity\n // \u03A3||p \u2212 f||\xB2 = \u03A3||p||\xB2 \u2212 2\xB7\u03B8\xB7rhs + \u03B8\u1D40G\u03B8 (G is the constant Gram matrix\n // of the fixed sample positions). Estimating with the CONTINUOUS corners\n // instead is blind to corner clamping and mis-picks planar on steep\n // gradients (a 1.4-normalised-SSE easy-block artifact on the colour\n // card). Only decode's floor-rounding stays unmodelled (\u2264 ~12 SSE).\n let shl = vec3<u32>(2u, 1u, 2u);\n let shr = vec3<u32>(4u, 6u, 4u);\n let eo = vec3<f32>((qo << shl) | (qo >> shr));\n let eh = vec3<f32>((qh << shl) | (qh >> shr));\n let ev = vec3<f32>((qv << shl) | (qv >> shr));\n let gram = 3.5 * (eo * eo + eh * eh + ev * ev) + 0.5 * eo * eh + 0.5 * eo * ev + 4.5 * eh * ev;\n let planar_est = sqtotal - 2.0 * (dot(eo, rA) + dot(eh, rB) + dot(ev, rC)) +\n dot(gram, vec3<f32>(1.0)) + PLANAR_FUDGE;\n\n // ------------------------------------------------------------ packing --\n var hi: u32;\n var lo: u32;\n if (best_est <= planar_est) {\n if (bdiff) {\n let d = vec3<u32>(vec3<i32>(codes1) - vec3<i32>(codes0)) & vec3<u32>(7u);\n hi = (codes0.r << 27u) | (d.r << 24u) | (codes0.g << 19u) | (d.g << 16u) | (codes0.b << 11u) | (d.b << 8u)\n | (t0 << 5u) | (t1 << 2u) | 2u | bflip;\n } else {\n hi = (codes0.r << 28u) | (codes1.r << 24u) | (codes0.g << 20u) | (codes1.g << 16u) | (codes0.b << 12u) | (codes1.b << 8u)\n | (t0 << 5u) | (t1 << 2u) | bflip;\n }\n lo = 0u;\n for (var sb: u32 = 0u; sb < 2u; sb = sb + 1u) {\n let indices = select(fit0, fit1, sb == 1u);\n for (var i: u32 = 0u; i < 8u; i = i + 1u) {\n let k = texel_of(bflip, sb, i);\n let wire = (k & 3u) * 4u + (k >> 2u);\n let idx = (indices >> (i * 2u)) & 3u;\n lo = lo | ((idx & 1u) << wire) | ((idx >> 1u) << (16u + wire));\n }\n }\n } else {\n let ro = qo.r; let go = qo.g; let bo = qo.b;\n let rh = qh.r; let gh = qh.g; let bh = qh.b;\n let rv = qv.r; let gv = qv.g; let bv = qv.b;\n let r_sum = i32(ro >> 2u) + signed3(((ro & 3u) << 1u) | (go >> 6u));\n let r_fix = select(0u, 1u, r_sum < 0);\n let g_sum = i32((go >> 2u) & 15u) + signed3(((go & 3u) << 1u) | (bo >> 5u));\n let g_fix = select(0u, 1u, g_sum < 0);\n let p = (bo >> 3u) & 3u;\n let q = (bo >> 1u) & 3u;\n let b_fix3 = select(0u, 7u, p + q >= 4u);\n let b_fix1 = select(1u, 0u, p + q >= 4u);\n hi = (r_fix << 31u) | (ro << 25u) | ((go >> 6u) << 24u) | (g_fix << 23u) | ((go & 63u) << 17u)\n | ((bo >> 5u) << 16u) | (b_fix3 << 13u) | (((bo >> 3u) & 3u) << 11u) | (b_fix1 << 10u)\n | ((bo & 7u) << 7u) | ((rh >> 1u) << 2u) | 2u | (rh & 1u);\n lo = (gh << 25u) | (bh << 19u) | (rv << 13u) | (gv << 6u) | bv;\n }\n\n let out = block_index * 2u;\n dst[out] = bswap(hi);\n dst[out + 1u] = bswap(lo);\n}\n";
1906
1856
 
1907
1857
  // src/etc2_fast_f16.wgsl
1908
- var etc2_fast_f16_default = "// ETC2 RGB8 compute shader encoder.\n//\n// Each invocation encodes one 4x4 pixel block into an 8-byte ETC2 RGB8 block\n// written as 2 x u32 into the destination storage buffer. ETC2 blocks are\n// big-endian on the wire (byte 0 = bits 63..56), so both words are byte-\n// swapped on the way out. This is that f16 module.\n//\n// EXACT-VALUE f16: unlike the other formats' f16 fast paths (which accept\n// float rounding in a [0,1] domain), every f16 value in this shader is an\n// integer that f16 represents exactly \u2014 lumas and bases (<= 765), D values\n// (|D| <= 765) and thresholds (<= 549) all sit below f16's 2048 integer-\n// exactness limit. Sums of squares, scores and estimates stay f32 (they\n// reach +-5e5..9e6, far past f16's 65504 max). The output is therefore\n// BYTE-IDENTICAL to the f32 module \u2014 verified per-block on the suite\n// textures \u2014 and the two modules share every pin and every test gate.\n//\n// What f16 buys here is register pressure (the luma array halves), not\n// arithmetic rate: on Apple/metal-3 the two modules measure identical\n// (the shader is DRAM-read-bound), but on the mobile GPUs where ETC2 is\n// actually the target format, occupancy from smaller registers is the\n// cheapest speed there is. The COLOUR accumulators deliberately stay f32\n// even though quadrant/pair sums (<= 2040) would be exact in f16: porting\n// them measured 15% SLOWER on Apple (conversion traffic outweighs the\n// register saving). Luma + the table search are the f16 surface.\n//\n// ALGORITHM \u2014 scalar-luma selection (2026-07 rewrite; the original\n// brute-force 8-table \xD7 4-modifier \xD7 vec3-with-clamp search measured\n// 6.0 ms @2048\xB2 on Apple/metal-3, this one ~0.197 ms with the DRAM read\n// floor \u2014 16 loads + store, nothing else \u2014 at ~0.15). This is the SETTLED\n// speed/quality point: the two-candidate scored search below was once\n// swapped for an O(1) hedged pick (\u22124-7% GPU) but cost \u22120.5 dB average \u2014\n// a ~10\xD7 worse dB-per-percent trade than the refit drop \u2014 and was\n// restored. A two-pass prepared-source variant (encode pass 0.115 ms) is\n// in git history: its prep pass is also DRAM-bound and cannot overlap,\n// so the per-texture total regressed. Reading the full RGBA8 source once\n// is this machine's hard floor for any single-pass encoder; the ~0.045\n// above it is the whole algorithm.\n//\n// \u2022 The ETC1 modifier is a SCALAR shift along (1,1,1), so per texel\n// err(m) = ||e||\xB2 \u2212 2mD + 3m\xB2 with D = luma(p) \u2212 luma(base), where\n// luma(x) = x.r+x.g+x.b. Selection therefore needs only |D| threshold\n// tests: the best table entry is the m with 3m nearest D (A3/B3/THR\n// below), and \u03A3||e||\xB2 per subblock is O(1) from the load loop's\n// quadrant sums (\u03A3||p||\xB2 \u2212 2\xB7base\xB7\u03A3p + 8\xB7||base||\xB2). This estimate is\n// EXACT for unclamped decode and an UPPER BOUND on the true clamped\n// error (clamping toward [0,255] can only shrink per-channel error),\n// so every est-based gate is conservative.\n// \u2022 Flip preselect, O(1): per subblock the residual after PERFECT\n// continuous luma modulation is within-variance \u2212 (luma variance)/3;\n// the flip with the smaller summed residual wins and only it is\n// searched (both-flip est search measured +23% GPU for \u22640.15 dB).\n// Exact-grayscale blocks have BOTH residuals identically zero (all\n// variance is along luma), so near-ties fall back to scoring both\n// flips \u2014 without that, roughness/AO-style content loses ~1.25 dB.\n// \u2022 Table search is pruned to two candidates \u2014 the table whose LARGE\n// magnitude covers max|D| and its lower neighbour (outlier hedge).\n// One candidate loses ~1.2-1.6 dB on photos; all eight gain \u22640.05 dB.\n// \u2022 NO base refit. The refit family (base \u2190 subblock mean \u2212 mean chosen\n// modifier) was worth ~0.2 dB on photographic colour (rock-color\n// 33.98 \u2192 33.79 without it) but even its cheapest accepted form cost\n// ~13% GPU and the exact-accept original ~30% \u2014 dropped 2026-07 as a\n// deliberate speed/quality trade; see the suite baselines.\n// \u2022 PLANAR runs unconditionally: with the right-hand sides folded into\n// the load loop the LSQ solve is O(1) (the Gram inverse of the fixed\n// sample positions is a constant, det = 25) and its residual is the\n// closed-form \u03A3||p||\xB2 \u2212 2\xB7\u03B8\xB7rhs + \u03B8\u1D40G\u03B8 evaluated with the QUANTISED,\n// clamped corners \u2014 exact up to decode's floor-rounding, and crucially\n// clamp-aware (a continuous-corner estimate mis-picks planar on steep\n// gradients). Gating planar cost \u22120.31 dB on smooth content for zero\n// measured speed.\n// \u2022 T and H modes are decoded by hardware but never emitted \u2014 their win\n// is limited to two-chroma-cluster blocks (the colour card's per-pixel\n// chroma checkers are the visible gap) and needs a clustering pass.\n//\n// Numeric notes: texel loads use round(load\xB7255) (integer-exact unorm trip);\n// every m3 in A3/B3 is divisible by 3 so m = m3/3 is exact; est values are\n// integer sums held exactly in f32 (< 2^24).\n\nenable f16;\n\nstruct Params {\n blocks_x: u32,\n blocks_y: u32,\n width: u32,\n height: u32,\n};\n\n@group(0) @binding(0) var src_tex: texture_2d<f32>;\n@group(0) @binding(1) var<storage, read_write> dst: array<u32>;\n@group(0) @binding(2) var<uniform> params: Params;\n\nconst A3 = array<f32, 8>(6.0, 15.0, 27.0, 39.0, 54.0, 72.0, 99.0, 141.0);\nconst B3 = array<f32, 8>(24.0, 51.0, 87.0, 126.0, 180.0, 240.0, 318.0, 549.0);\nconst THR = array<f32, 8>(15.0, 33.0, 57.0, 82.5, 117.0, 156.0, 208.5, 345.0);\n\n// Planar's closed-form estimate models the QUANTISED corners exactly; only\n// decode's floor-rounding (\xB1\xBD per sample) is unmodelled. This small bias\n// keeps near-ties on the predictable ETC1 side.\nconst PLANAR_FUDGE = 8.0;\n\nfn texel_of(flip: u32, sb: u32, i: u32) -> u32 {\n if (flip == 0u) {\n return (i >> 1u) * 4u + sb * 2u + (i & 1u);\n }\n return (sb * 2u + (i >> 2u)) * 4u + (i & 3u);\n}\n\nfn quant_codes(v: vec3<f32>, max_code: vec3<f32>) -> vec3<u32> {\n return vec3<u32>(clamp(floor(v * max_code * (1.0 / 255.0) + 0.5), vec3<f32>(0.0), max_code));\n}\n\nfn extend4(c: vec3<u32>) -> vec3<f32> {\n return vec3<f32>((c << vec3<u32>(4u)) | c);\n}\nfn extend5(c: vec3<u32>) -> vec3<f32> {\n return vec3<f32>((c << vec3<u32>(3u)) | (c >> vec3<u32>(2u)));\n}\n\nfn signed3(bits: u32) -> i32 {\n return select(i32(bits), i32(bits) - 8, bits > 3u);\n}\n\nfn bswap(x: u32) -> u32 {\n return ((x & 0xffu) << 24u) | ((x & 0xff00u) << 8u) | ((x >> 8u) & 0xff00u) | (x >> 24u);\n}\n\nstruct BasePair {\n codes0: vec3<u32>,\n codes1: vec3<u32>,\n ok: bool,\n};\nfn quantise_bases(avg0: vec3<f32>, avg1: vec3<f32>, diff: bool, clamp_delta: bool) -> BasePair {\n var out: BasePair;\n out.ok = true;\n if (!diff) {\n out.codes0 = quant_codes(avg0, vec3<f32>(15.0));\n out.codes1 = quant_codes(avg1, vec3<f32>(15.0));\n return out;\n }\n let q0 = vec3<i32>(quant_codes(avg0, vec3<f32>(31.0)));\n let q1 = vec3<i32>(quant_codes(avg1, vec3<f32>(31.0)));\n let d = q1 - q0;\n if (any(d < vec3<i32>(-4)) || any(d > vec3<i32>(3))) {\n if (!clamp_delta) {\n out.ok = false;\n return out;\n }\n }\n out.codes0 = vec3<u32>(q0);\n out.codes1 = vec3<u32>(q0 + clamp(d, vec3<i32>(-4), vec3<i32>(3)));\n return out;\n}\n\nstruct SearchOut {\n table: u32,\n acc: f32,\n};\n// D-domain values (|D| <= 765, thresholds <= 549) are exact in f16; the\n// score PRODUCTS reach +-5e5 and must be f32.\nfn sb_table_score(luma: ptr<function, array<f16, 16>>, flip: u32, sb: u32, lb: f16, t: u32) -> f32 {\n let a3 = f16(A3[t]);\n let b3 = f16(B3[t]);\n let thr = f16(THR[t]);\n var acc = 0.0;\n for (var i: u32 = 0u; i < 8u; i = i + 1u) {\n let ad = abs((*luma)[texel_of(flip, sb, i)] - lb);\n let m3 = f32(select(a3, b3, ad > thr));\n acc = acc + m3 * (m3 - 2.0 * f32(ad));\n }\n return acc;\n}\nfn sb_search(luma: ptr<function, array<f16, 16>>, flip: u32, sb: u32, lb: f16) -> SearchOut {\n var mx: f16 = 0.0;\n for (var i: u32 = 0u; i < 8u; i = i + 1u) {\n mx = max(mx, abs((*luma)[texel_of(flip, sb, i)] - lb));\n }\n let mxf = f32(mx);\n let cover = min(\n u32(mxf > 24.0) + u32(mxf > 51.0) + u32(mxf > 87.0) + u32(mxf > 126.0) +\n u32(mxf > 180.0) + u32(mxf > 240.0) + u32(mxf > 318.0),\n 7u,\n );\n let t_lo = select(cover - 1u, 0u, cover == 0u);\n let acc_lo = sb_table_score(luma, flip, sb, lb, t_lo);\n let acc_hi = sb_table_score(luma, flip, sb, lb, cover);\n var out: SearchOut;\n let lo_wins = acc_lo <= acc_hi;\n out.table = select(cover, t_lo, lo_wins);\n out.acc = select(acc_hi, acc_lo, lo_wins);\n return out;\n}\n\n// One flip's base quantisation + table search: everything the flip contest\n// and the index derivation need.\nstruct FlipFit {\n est: f32,\n diff: bool,\n bases: BasePair,\n lb0: f32,\n lb1: f32,\n t0: u32,\n t1: u32,\n};\nfn eval_flip(\n luma: ptr<function, array<f16, 16>>,\n flip: u32,\n sum0: vec3<f32>,\n sq0: f32,\n sum1: vec3<f32>,\n sq1: f32,\n) -> FlipFit {\n let avg0 = sum0 * 0.125;\n let avg1 = sum1 * 0.125;\n let try_diff = quantise_bases(avg0, avg1, true, false);\n var out: FlipFit;\n out.diff = try_diff.ok;\n if (out.diff) {\n out.bases = try_diff;\n } else {\n out.bases = quantise_bases(avg0, avg1, false, false);\n }\n var b0: vec3<f32>;\n var b1: vec3<f32>;\n if (out.diff) {\n b0 = extend5(out.bases.codes0);\n b1 = extend5(out.bases.codes1);\n } else {\n b0 = extend4(out.bases.codes0);\n b1 = extend4(out.bases.codes1);\n }\n out.lb0 = b0.r + b0.g + b0.b;\n out.lb1 = b1.r + b1.g + b1.b;\n let s0 = sb_search(luma, flip, 0u, f16(out.lb0));\n let s1 = sb_search(luma, flip, 1u, f16(out.lb1));\n out.t0 = s0.table;\n out.t1 = s1.table;\n out.est = (sq0 - 2.0 * dot(b0, sum0) + 8.0 * dot(b0, b0)) +\n (sq1 - 2.0 * dot(b1, sum1) + 8.0 * dot(b1, b1)) +\n (s0.acc + s1.acc) * (1.0 / 3.0);\n return out;\n}\n\n// Wire indices for a chosen table \u2014 computed ONCE, from the final base.\nfn sb_indices(luma: ptr<function, array<f16, 16>>, flip: u32, sb: u32, lb: f16, t: u32) -> u32 {\n let thr = f16(THR[t]);\n var indices = 0u;\n for (var i: u32 = 0u; i < 8u; i = i + 1u) {\n let d = (*luma)[texel_of(flip, sb, i)] - lb;\n let large = abs(d) > thr;\n let neg = d < 0.0;\n indices = indices | ((select(0u, 1u, large) | select(0u, 2u, neg)) << (i * 2u));\n }\n return indices;\n}\n\n@compute @workgroup_size(8, 8, 1)\nfn encode(@builtin(global_invocation_id) gid: vec3<u32>) {\n if (gid.x >= params.blocks_x || gid.y >= params.blocks_y) {\n return;\n }\n\n let block_index = gid.y * params.blocks_x + gid.x;\n let base_xy = vec2<i32>(i32(gid.x) * 4, i32(gid.y) * 4);\n let max_xy = vec2<i32>(i32(params.width) - 1, i32(params.height) - 1);\n\n // Luma lives in f16: every value is an integer <= 765, exact in f16.\n var luma: array<f16, 16>;\n var qsum: array<vec3<f32>, 4>;\n var qsq: array<f32, 4>;\n var qlsq: array<f32, 4>;\n // Planar right-hand sides, folded into the load: rB = \u03A3 (x/4)\xB7p and\n // rC = \u03A3 (y/4)\xB7p accumulate unscaled; rA = \u03A3p \u2212 rB \u2212 rC afterwards.\n var sxp = vec3<f32>(0.0);\n var syp = vec3<f32>(0.0);\n\n for (var i: u32 = 0u; i < 16u; i = i + 1u) {\n let lx = i32(i & 3u);\n let ly = i32(i >> 2u);\n let p = clamp(base_xy + vec2<i32>(lx, ly), vec2<i32>(0, 0), max_xy);\n let c = round(textureLoad(src_tex, p, 0).rgb * 255.0);\n let l = c.r + c.g + c.b;\n luma[i] = f16(l);\n let q = u32(lx >= 2) | (u32(ly >= 2) << 1u);\n qsum[q] = qsum[q] + c;\n qsq[q] = qsq[q] + dot(c, c);\n qlsq[q] = qlsq[q] + l * l;\n sxp = sxp + f32(lx) * c;\n syp = syp + f32(ly) * c;\n }\n\n // ----------------------------------------------- flip + base selection --\n // Flip preselect, O(1) from quadrant sums: per subblock the residual after\n // PERFECT continuous luma modulation is (\u03A3||p||\xB2 \u2212 ||\u03A3p||\xB2/8) \u2212\n // (\u03A3\u2113\xB2 \u2212 (\u03A3\u2113)\xB2/8)/3 \u2014 the within-variance minus the (1,1,1)-direction\n // component the modifier tables can absorb. The flip minimising the summed\n // residual wins and only it gets the table search \u2014 EXCEPT when the two\n // residuals are indistinguishable: for exact-grayscale blocks (r=g=b) both\n // are identically zero, so the contest falls back to scoring both flips\n // (this recovered \u22121.25 dB on roughness/AO-style content).\n let sum0a = qsum[0] + qsum[2];\n let sum1a = qsum[1] + qsum[3];\n let sq0a = qsq[0] + qsq[2];\n let sq1a = qsq[1] + qsq[3];\n let sum0b = qsum[0] + qsum[1];\n let sum1b = qsum[2] + qsum[3];\n let sq0b = qsq[0] + qsq[1];\n let sq1b = qsq[2] + qsq[3];\n let lsq0a = qlsq[0] + qlsq[2];\n let lsq1a = qlsq[1] + qlsq[3];\n let lsq0b = qlsq[0] + qlsq[1];\n let lsq1b = qlsq[2] + qlsq[3];\n let res_a = (sq0a - dot(sum0a, sum0a) * 0.125) - (lsq0a - dot(sum0a, vec3<f32>(1.0)) * dot(sum0a, vec3<f32>(1.0)) * 0.125) * (1.0 / 3.0)\n + (sq1a - dot(sum1a, sum1a) * 0.125) - (lsq1a - dot(sum1a, vec3<f32>(1.0)) * dot(sum1a, vec3<f32>(1.0)) * 0.125) * (1.0 / 3.0);\n let res_b = (sq0b - dot(sum0b, sum0b) * 0.125) - (lsq0b - dot(sum0b, vec3<f32>(1.0)) * dot(sum0b, vec3<f32>(1.0)) * 0.125) * (1.0 / 3.0)\n + (sq1b - dot(sum1b, sum1b) * 0.125) - (lsq1b - dot(sum1b, vec3<f32>(1.0)) * dot(sum1b, vec3<f32>(1.0)) * 0.125) * (1.0 / 3.0);\n\n // Single eval_flip call site (a second inlined copy measured +50% GPU):\n // attempt 0 scores the primary flip, attempt 1 runs only in the dual\n // (indistinguishable-residuals) case and scores the other flip.\n let dual = abs(res_a - res_b) < 1.0;\n let primary = select(select(0u, 1u, res_b < res_a), 0u, dual);\n var bflip = primary;\n var sel: FlipFit;\n for (var attempt = 0u; attempt < 2u; attempt = attempt + 1u) {\n if (attempt == 1u && !dual) {\n break;\n }\n let f = select(primary, 1u, attempt == 1u);\n let cand = eval_flip(\n &luma,\n f,\n select(sum0a, sum0b, f == 1u),\n select(sq0a, sq0b, f == 1u),\n select(sum1a, sum1b, f == 1u),\n select(sq1a, sq1b, f == 1u),\n );\n if (attempt == 0u || cand.est < sel.est) {\n sel = cand;\n bflip = f;\n }\n }\n let bdiff = sel.diff;\n\n let best_est = sel.est;\n let codes0 = sel.bases.codes0;\n let codes1 = sel.bases.codes1;\n let t0 = sel.t0;\n let t1 = sel.t1;\n let fit0 = sb_indices(&luma, bflip, 0u, f16(sel.lb0), t0);\n let fit1 = sb_indices(&luma, bflip, 1u, f16(sel.lb1), t1);\n\n // ------------------------------------------------------------ planar --\n // Always evaluated: with the rhs folded into the load loop this is O(1),\n // and gating it on the ETC1 estimate measured \u22120.31 dB on smooth content\n // for zero speed.\n let total = qsum[0] + qsum[1] + qsum[2] + qsum[3];\n let sqtotal = qsq[0] + qsq[1] + qsq[2] + qsq[3];\n let rB = sxp * 0.25;\n let rC = syp * 0.25;\n let rA = total - rB - rC;\n let po = 0.2875 * rA - 0.0125 * rB - 0.0125 * rC;\n let ph = -0.0125 * rA + 0.4875 * rB - 0.3125 * rC;\n let pv = -0.0125 * rA - 0.3125 * rB + 0.4875 * rC;\n let pmax = vec3<f32>(63.0, 127.0, 63.0);\n let qo = quant_codes(po, pmax);\n let qh = quant_codes(ph, pmax);\n let qv = quant_codes(pv, pmax);\n // Residual of the plane the hardware will ACTUALLY decode \u2014 the\n // quantised, clamped corners \u2014 via the normal-equation identity\n // \u03A3||p \u2212 f||\xB2 = \u03A3||p||\xB2 \u2212 2\xB7\u03B8\xB7rhs + \u03B8\u1D40G\u03B8 (G is the constant Gram matrix\n // of the fixed sample positions). Estimating with the CONTINUOUS corners\n // instead is blind to corner clamping and mis-picks planar on steep\n // gradients (a 1.4-normalised-SSE easy-block artifact on the colour\n // card). Only decode's floor-rounding stays unmodelled (\u2264 ~12 SSE).\n let shl = vec3<u32>(2u, 1u, 2u);\n let shr = vec3<u32>(4u, 6u, 4u);\n let eo = vec3<f32>((qo << shl) | (qo >> shr));\n let eh = vec3<f32>((qh << shl) | (qh >> shr));\n let ev = vec3<f32>((qv << shl) | (qv >> shr));\n let gram = 3.5 * (eo * eo + eh * eh + ev * ev) + 0.5 * eo * eh + 0.5 * eo * ev + 4.5 * eh * ev;\n let planar_est = sqtotal - 2.0 * (dot(eo, rA) + dot(eh, rB) + dot(ev, rC)) +\n dot(gram, vec3<f32>(1.0)) + PLANAR_FUDGE;\n\n // ------------------------------------------------------------ packing --\n var hi: u32;\n var lo: u32;\n if (best_est <= planar_est) {\n if (bdiff) {\n let d = vec3<u32>(vec3<i32>(codes1) - vec3<i32>(codes0)) & vec3<u32>(7u);\n hi = (codes0.r << 27u) | (d.r << 24u) | (codes0.g << 19u) | (d.g << 16u) | (codes0.b << 11u) | (d.b << 8u)\n | (t0 << 5u) | (t1 << 2u) | 2u | bflip;\n } else {\n hi = (codes0.r << 28u) | (codes1.r << 24u) | (codes0.g << 20u) | (codes1.g << 16u) | (codes0.b << 12u) | (codes1.b << 8u)\n | (t0 << 5u) | (t1 << 2u) | bflip;\n }\n lo = 0u;\n for (var sb: u32 = 0u; sb < 2u; sb = sb + 1u) {\n let indices = select(fit0, fit1, sb == 1u);\n for (var i: u32 = 0u; i < 8u; i = i + 1u) {\n let k = texel_of(bflip, sb, i);\n let wire = (k & 3u) * 4u + (k >> 2u);\n let idx = (indices >> (i * 2u)) & 3u;\n lo = lo | ((idx & 1u) << wire) | ((idx >> 1u) << (16u + wire));\n }\n }\n } else {\n let ro = qo.r; let go = qo.g; let bo = qo.b;\n let rh = qh.r; let gh = qh.g; let bh = qh.b;\n let rv = qv.r; let gv = qv.g; let bv = qv.b;\n let r_sum = i32(ro >> 2u) + signed3(((ro & 3u) << 1u) | (go >> 6u));\n let r_fix = select(0u, 1u, r_sum < 0);\n let g_sum = i32((go >> 2u) & 15u) + signed3(((go & 3u) << 1u) | (bo >> 5u));\n let g_fix = select(0u, 1u, g_sum < 0);\n let p = (bo >> 3u) & 3u;\n let q = (bo >> 1u) & 3u;\n let b_fix3 = select(0u, 7u, p + q >= 4u);\n let b_fix1 = select(1u, 0u, p + q >= 4u);\n hi = (r_fix << 31u) | (ro << 25u) | ((go >> 6u) << 24u) | (g_fix << 23u) | ((go & 63u) << 17u)\n | ((bo >> 5u) << 16u) | (b_fix3 << 13u) | (((bo >> 3u) & 3u) << 11u) | (b_fix1 << 10u)\n | ((bo & 7u) << 7u) | ((rh >> 1u) << 2u) | 2u | (rh & 1u);\n lo = (gh << 25u) | (bh << 19u) | (rv << 13u) | (gv << 6u) | bv;\n }\n\n let out = block_index * 2u;\n dst[out] = bswap(hi);\n dst[out + 1u] = bswap(lo);\n}\n";
1858
+ var etc2_fast_f16_default = "// ETC2 RGB8 compute shader encoder.\n//\n// Each invocation encodes one 4x4 pixel block into an 8-byte ETC2 RGB8 block\n// written as 2 x u32 into the destination storage buffer. ETC2 blocks are\n// big-endian on the wire (byte 0 = bits 63..56), so both words are byte-\n// swapped on the way out. This is that f16 module.\n//\n// EXACT-VALUE f16: unlike the other formats' f16 fast paths (which accept\n// float rounding in a [0,1] domain), every f16 value in this shader is an\n// integer that f16 represents exactly \u2014 lumas and bases (<= 765), D values\n// (|D| <= 765) and thresholds (<= 549) all sit below f16's 2048 integer-\n// exactness limit. Sums of squares, scores and estimates stay f32 (they\n// reach +-5e5..9e6, far past f16's 65504 max). The output is therefore\n// BYTE-IDENTICAL to the f32 module \u2014 verified per-block on the suite\n// textures \u2014 and the two modules share every pin and every test gate.\n//\n// What f16 buys here is register pressure (the luma array halves), not\n// arithmetic rate: on Apple/metal-3 the two modules measure identical\n// (the shader is DRAM-read-bound), but on the mobile GPUs where ETC2 is\n// actually the target format, occupancy from smaller registers is the\n// cheapest speed there is. The COLOUR accumulators deliberately stay f32\n// even though quadrant/pair sums (<= 2040) would be exact in f16: porting\n// them measured 15% SLOWER on Apple (conversion traffic outweighs the\n// register saving). Luma + the table search are the f16 surface.\n//\n// ALGORITHM \u2014 scalar-luma selection (2026-07 rewrite; the original\n// brute-force 8-table \xD7 4-modifier \xD7 vec3-with-clamp search measured\n// 6.0 ms @2048\xB2 on Apple/metal-3, this one ~0.197 ms with the DRAM read\n// floor \u2014 16 loads + store, nothing else \u2014 at ~0.15). This is the SETTLED\n// speed/quality point: the two-candidate scored search below was once\n// swapped for an O(1) hedged pick (\u22124-7% GPU) but cost \u22120.5 dB average \u2014\n// a ~10\xD7 worse dB-per-percent trade than the refit drop \u2014 and was\n// restored. A two-pass prepared-source variant (encode pass 0.115 ms) is\n// in git history: its prep pass is also DRAM-bound and cannot overlap,\n// so the per-texture total regressed. Reading the full RGBA8 source once\n// is this machine's hard floor for any single-pass encoder; the ~0.045\n// above it is the whole algorithm.\n//\n// \u2022 The ETC1 modifier is a SCALAR shift along (1,1,1), so per texel\n// err(m) = ||e||\xB2 \u2212 2mD + 3m\xB2 with D = luma(p) \u2212 luma(base), where\n// luma(x) = x.r+x.g+x.b. Selection therefore needs only |D| threshold\n// tests: the best table entry is the m with 3m nearest D (A3/B3/THR\n// below), and \u03A3||e||\xB2 per subblock is O(1) from the load loop's\n// quadrant sums (\u03A3||p||\xB2 \u2212 2\xB7base\xB7\u03A3p + 8\xB7||base||\xB2). This estimate is\n// EXACT for unclamped decode and an UPPER BOUND on the true clamped\n// error (clamping toward [0,255] can only shrink per-channel error),\n// so every est-based gate is conservative.\n// \u2022 Flip preselect, O(1): per subblock the residual after PERFECT\n// continuous luma modulation is within-variance \u2212 (luma variance)/3;\n// the flip with the smaller summed residual wins and only it is\n// searched (both-flip est search measured +23% GPU for \u22640.15 dB).\n// Exact-grayscale blocks have BOTH residuals identically zero (all\n// variance is along luma), so near-ties fall back to scoring both\n// flips \u2014 without that, roughness/AO-style content loses ~1.25 dB.\n// \u2022 Table search is pruned to two candidates \u2014 the table whose LARGE\n// magnitude covers max|D| and its lower neighbour (outlier hedge).\n// One candidate loses ~1.2-1.6 dB on photos; all eight gain \u22640.05 dB.\n// \u2022 NO base refit. The refit family (base \u2190 subblock mean \u2212 mean chosen\n// modifier) was worth ~0.2 dB on photographic colour (rock-color\n// 33.98 \u2192 33.79 without it) but even its cheapest accepted form cost\n// ~13% GPU and the exact-accept original ~30% \u2014 dropped 2026-07 as a\n// deliberate speed/quality trade; see the suite baselines.\n// \u2022 PLANAR runs unconditionally: with the right-hand sides folded into\n// the load loop the LSQ solve is O(1) (the Gram inverse of the fixed\n// sample positions is a constant, det = 25) and its residual is the\n// closed-form \u03A3||p||\xB2 \u2212 2\xB7\u03B8\xB7rhs + \u03B8\u1D40G\u03B8 evaluated with the QUANTISED,\n// clamped corners \u2014 exact up to decode's floor-rounding, and crucially\n// clamp-aware (a continuous-corner estimate mis-picks planar on steep\n// gradients). Gating planar cost \u22120.31 dB on smooth content for zero\n// measured speed.\n// \u2022 T and H modes are decoded by hardware but never emitted \u2014 their win\n// is limited to two-chroma-cluster blocks (the colour card's per-pixel\n// chroma checkers are the visible gap) and needs a clustering pass.\n//\n// Numeric notes: texel loads use round(load\xB7255) (integer-exact unorm trip);\n// every m3 in A3/B3 is divisible by 3 so m = m3/3 is exact; est values are\n// integer sums held exactly in f32 (< 2^24).\n\nenable f16;\n\nstruct Params {\n blocks_x: u32,\n blocks_y: u32,\n width: u32,\n height: u32,\n y0: u32, // first block row of this dispatch (row-band encodes)\n};\n\n@group(0) @binding(0) var src_tex: texture_2d<f32>;\n@group(0) @binding(1) var<storage, read_write> dst: array<u32>;\n@group(0) @binding(2) var<uniform> params: Params;\n\nconst A3 = array<f32, 8>(6.0, 15.0, 27.0, 39.0, 54.0, 72.0, 99.0, 141.0);\nconst B3 = array<f32, 8>(24.0, 51.0, 87.0, 126.0, 180.0, 240.0, 318.0, 549.0);\nconst THR = array<f32, 8>(15.0, 33.0, 57.0, 82.5, 117.0, 156.0, 208.5, 345.0);\n\n// Planar's closed-form estimate models the QUANTISED corners exactly; only\n// decode's floor-rounding (\xB1\xBD per sample) is unmodelled. This small bias\n// keeps near-ties on the predictable ETC1 side.\nconst PLANAR_FUDGE = 8.0;\n\nfn texel_of(flip: u32, sb: u32, i: u32) -> u32 {\n if (flip == 0u) {\n return (i >> 1u) * 4u + sb * 2u + (i & 1u);\n }\n return (sb * 2u + (i >> 2u)) * 4u + (i & 3u);\n}\n\nfn quant_codes(v: vec3<f32>, max_code: vec3<f32>) -> vec3<u32> {\n return vec3<u32>(clamp(floor(v * max_code * (1.0 / 255.0) + 0.5), vec3<f32>(0.0), max_code));\n}\n\nfn extend4(c: vec3<u32>) -> vec3<f32> {\n return vec3<f32>((c << vec3<u32>(4u)) | c);\n}\nfn extend5(c: vec3<u32>) -> vec3<f32> {\n return vec3<f32>((c << vec3<u32>(3u)) | (c >> vec3<u32>(2u)));\n}\n\nfn signed3(bits: u32) -> i32 {\n return select(i32(bits), i32(bits) - 8, bits > 3u);\n}\n\nfn bswap(x: u32) -> u32 {\n return ((x & 0xffu) << 24u) | ((x & 0xff00u) << 8u) | ((x >> 8u) & 0xff00u) | (x >> 24u);\n}\n\nstruct BasePair {\n codes0: vec3<u32>,\n codes1: vec3<u32>,\n ok: bool,\n};\nfn quantise_bases(avg0: vec3<f32>, avg1: vec3<f32>, diff: bool, clamp_delta: bool) -> BasePair {\n var out: BasePair;\n out.ok = true;\n if (!diff) {\n out.codes0 = quant_codes(avg0, vec3<f32>(15.0));\n out.codes1 = quant_codes(avg1, vec3<f32>(15.0));\n return out;\n }\n let q0 = vec3<i32>(quant_codes(avg0, vec3<f32>(31.0)));\n let q1 = vec3<i32>(quant_codes(avg1, vec3<f32>(31.0)));\n let d = q1 - q0;\n if (any(d < vec3<i32>(-4)) || any(d > vec3<i32>(3))) {\n if (!clamp_delta) {\n out.ok = false;\n return out;\n }\n }\n out.codes0 = vec3<u32>(q0);\n out.codes1 = vec3<u32>(q0 + clamp(d, vec3<i32>(-4), vec3<i32>(3)));\n return out;\n}\n\nstruct SearchOut {\n table: u32,\n acc: f32,\n};\n// D-domain values (|D| <= 765, thresholds <= 549) are exact in f16; the\n// score PRODUCTS reach +-5e5 and must be f32.\nfn sb_table_score(luma: ptr<function, array<f16, 16>>, flip: u32, sb: u32, lb: f16, t: u32) -> f32 {\n let a3 = f16(A3[t]);\n let b3 = f16(B3[t]);\n let thr = f16(THR[t]);\n var acc = 0.0;\n for (var i: u32 = 0u; i < 8u; i = i + 1u) {\n let ad = abs((*luma)[texel_of(flip, sb, i)] - lb);\n let m3 = f32(select(a3, b3, ad > thr));\n acc = acc + m3 * (m3 - 2.0 * f32(ad));\n }\n return acc;\n}\nfn sb_search(luma: ptr<function, array<f16, 16>>, flip: u32, sb: u32, lb: f16) -> SearchOut {\n var mx: f16 = 0.0;\n for (var i: u32 = 0u; i < 8u; i = i + 1u) {\n mx = max(mx, abs((*luma)[texel_of(flip, sb, i)] - lb));\n }\n let mxf = f32(mx);\n let cover = min(\n u32(mxf > 24.0) + u32(mxf > 51.0) + u32(mxf > 87.0) + u32(mxf > 126.0) +\n u32(mxf > 180.0) + u32(mxf > 240.0) + u32(mxf > 318.0),\n 7u,\n );\n let t_lo = select(cover - 1u, 0u, cover == 0u);\n let acc_lo = sb_table_score(luma, flip, sb, lb, t_lo);\n let acc_hi = sb_table_score(luma, flip, sb, lb, cover);\n var out: SearchOut;\n let lo_wins = acc_lo <= acc_hi;\n out.table = select(cover, t_lo, lo_wins);\n out.acc = select(acc_hi, acc_lo, lo_wins);\n return out;\n}\n\n// One flip's base quantisation + table search: everything the flip contest\n// and the index derivation need.\nstruct FlipFit {\n est: f32,\n diff: bool,\n bases: BasePair,\n lb0: f32,\n lb1: f32,\n t0: u32,\n t1: u32,\n};\nfn eval_flip(\n luma: ptr<function, array<f16, 16>>,\n flip: u32,\n sum0: vec3<f32>,\n sq0: f32,\n sum1: vec3<f32>,\n sq1: f32,\n) -> FlipFit {\n let avg0 = sum0 * 0.125;\n let avg1 = sum1 * 0.125;\n let try_diff = quantise_bases(avg0, avg1, true, false);\n var out: FlipFit;\n out.diff = try_diff.ok;\n if (out.diff) {\n out.bases = try_diff;\n } else {\n out.bases = quantise_bases(avg0, avg1, false, false);\n }\n var b0: vec3<f32>;\n var b1: vec3<f32>;\n if (out.diff) {\n b0 = extend5(out.bases.codes0);\n b1 = extend5(out.bases.codes1);\n } else {\n b0 = extend4(out.bases.codes0);\n b1 = extend4(out.bases.codes1);\n }\n out.lb0 = b0.r + b0.g + b0.b;\n out.lb1 = b1.r + b1.g + b1.b;\n let s0 = sb_search(luma, flip, 0u, f16(out.lb0));\n let s1 = sb_search(luma, flip, 1u, f16(out.lb1));\n out.t0 = s0.table;\n out.t1 = s1.table;\n out.est = (sq0 - 2.0 * dot(b0, sum0) + 8.0 * dot(b0, b0)) +\n (sq1 - 2.0 * dot(b1, sum1) + 8.0 * dot(b1, b1)) +\n (s0.acc + s1.acc) * (1.0 / 3.0);\n return out;\n}\n\n// Wire indices for a chosen table \u2014 computed ONCE, from the final base.\nfn sb_indices(luma: ptr<function, array<f16, 16>>, flip: u32, sb: u32, lb: f16, t: u32) -> u32 {\n let thr = f16(THR[t]);\n var indices = 0u;\n for (var i: u32 = 0u; i < 8u; i = i + 1u) {\n let d = (*luma)[texel_of(flip, sb, i)] - lb;\n let large = abs(d) > thr;\n let neg = d < 0.0;\n indices = indices | ((select(0u, 1u, large) | select(0u, 2u, neg)) << (i * 2u));\n }\n return indices;\n}\n\n@compute @workgroup_size(8, 8, 1)\nfn encode(@builtin(global_invocation_id) gid_raw: vec3<u32>) {\n // Row-band encodes dispatch a slice of the block grid starting at row y0.\n let gid = vec3<u32>(gid_raw.x, gid_raw.y + params.y0, gid_raw.z);\n if (gid.x >= params.blocks_x || gid.y >= params.blocks_y) {\n return;\n }\n\n let block_index = gid.y * params.blocks_x + gid.x;\n let base_xy = vec2<i32>(i32(gid.x) * 4, i32(gid.y) * 4);\n let max_xy = vec2<i32>(i32(params.width) - 1, i32(params.height) - 1);\n\n // Luma lives in f16: every value is an integer <= 765, exact in f16.\n var luma: array<f16, 16>;\n var qsum: array<vec3<f32>, 4>;\n var qsq: array<f32, 4>;\n var qlsq: array<f32, 4>;\n // Planar right-hand sides, folded into the load: rB = \u03A3 (x/4)\xB7p and\n // rC = \u03A3 (y/4)\xB7p accumulate unscaled; rA = \u03A3p \u2212 rB \u2212 rC afterwards.\n var sxp = vec3<f32>(0.0);\n var syp = vec3<f32>(0.0);\n\n for (var i: u32 = 0u; i < 16u; i = i + 1u) {\n let lx = i32(i & 3u);\n let ly = i32(i >> 2u);\n let p = clamp(base_xy + vec2<i32>(lx, ly), vec2<i32>(0, 0), max_xy);\n let c = round(textureLoad(src_tex, p, 0).rgb * 255.0);\n let l = c.r + c.g + c.b;\n luma[i] = f16(l);\n let q = u32(lx >= 2) | (u32(ly >= 2) << 1u);\n qsum[q] = qsum[q] + c;\n qsq[q] = qsq[q] + dot(c, c);\n qlsq[q] = qlsq[q] + l * l;\n sxp = sxp + f32(lx) * c;\n syp = syp + f32(ly) * c;\n }\n\n // ----------------------------------------------- flip + base selection --\n // Flip preselect, O(1) from quadrant sums: per subblock the residual after\n // PERFECT continuous luma modulation is (\u03A3||p||\xB2 \u2212 ||\u03A3p||\xB2/8) \u2212\n // (\u03A3\u2113\xB2 \u2212 (\u03A3\u2113)\xB2/8)/3 \u2014 the within-variance minus the (1,1,1)-direction\n // component the modifier tables can absorb. The flip minimising the summed\n // residual wins and only it gets the table search \u2014 EXCEPT when the two\n // residuals are indistinguishable: for exact-grayscale blocks (r=g=b) both\n // are identically zero, so the contest falls back to scoring both flips\n // (this recovered \u22121.25 dB on roughness/AO-style content).\n let sum0a = qsum[0] + qsum[2];\n let sum1a = qsum[1] + qsum[3];\n let sq0a = qsq[0] + qsq[2];\n let sq1a = qsq[1] + qsq[3];\n let sum0b = qsum[0] + qsum[1];\n let sum1b = qsum[2] + qsum[3];\n let sq0b = qsq[0] + qsq[1];\n let sq1b = qsq[2] + qsq[3];\n let lsq0a = qlsq[0] + qlsq[2];\n let lsq1a = qlsq[1] + qlsq[3];\n let lsq0b = qlsq[0] + qlsq[1];\n let lsq1b = qlsq[2] + qlsq[3];\n let res_a = (sq0a - dot(sum0a, sum0a) * 0.125) - (lsq0a - dot(sum0a, vec3<f32>(1.0)) * dot(sum0a, vec3<f32>(1.0)) * 0.125) * (1.0 / 3.0)\n + (sq1a - dot(sum1a, sum1a) * 0.125) - (lsq1a - dot(sum1a, vec3<f32>(1.0)) * dot(sum1a, vec3<f32>(1.0)) * 0.125) * (1.0 / 3.0);\n let res_b = (sq0b - dot(sum0b, sum0b) * 0.125) - (lsq0b - dot(sum0b, vec3<f32>(1.0)) * dot(sum0b, vec3<f32>(1.0)) * 0.125) * (1.0 / 3.0)\n + (sq1b - dot(sum1b, sum1b) * 0.125) - (lsq1b - dot(sum1b, vec3<f32>(1.0)) * dot(sum1b, vec3<f32>(1.0)) * 0.125) * (1.0 / 3.0);\n\n // Single eval_flip call site (a second inlined copy measured +50% GPU):\n // attempt 0 scores the primary flip, attempt 1 runs only in the dual\n // (indistinguishable-residuals) case and scores the other flip.\n let dual = abs(res_a - res_b) < 1.0;\n let primary = select(select(0u, 1u, res_b < res_a), 0u, dual);\n var bflip = primary;\n var sel: FlipFit;\n for (var attempt = 0u; attempt < 2u; attempt = attempt + 1u) {\n if (attempt == 1u && !dual) {\n break;\n }\n let f = select(primary, 1u, attempt == 1u);\n let cand = eval_flip(\n &luma,\n f,\n select(sum0a, sum0b, f == 1u),\n select(sq0a, sq0b, f == 1u),\n select(sum1a, sum1b, f == 1u),\n select(sq1a, sq1b, f == 1u),\n );\n if (attempt == 0u || cand.est < sel.est) {\n sel = cand;\n bflip = f;\n }\n }\n let bdiff = sel.diff;\n\n let best_est = sel.est;\n let codes0 = sel.bases.codes0;\n let codes1 = sel.bases.codes1;\n let t0 = sel.t0;\n let t1 = sel.t1;\n let fit0 = sb_indices(&luma, bflip, 0u, f16(sel.lb0), t0);\n let fit1 = sb_indices(&luma, bflip, 1u, f16(sel.lb1), t1);\n\n // ------------------------------------------------------------ planar --\n // Always evaluated: with the rhs folded into the load loop this is O(1),\n // and gating it on the ETC1 estimate measured \u22120.31 dB on smooth content\n // for zero speed.\n let total = qsum[0] + qsum[1] + qsum[2] + qsum[3];\n let sqtotal = qsq[0] + qsq[1] + qsq[2] + qsq[3];\n let rB = sxp * 0.25;\n let rC = syp * 0.25;\n let rA = total - rB - rC;\n let po = 0.2875 * rA - 0.0125 * rB - 0.0125 * rC;\n let ph = -0.0125 * rA + 0.4875 * rB - 0.3125 * rC;\n let pv = -0.0125 * rA - 0.3125 * rB + 0.4875 * rC;\n let pmax = vec3<f32>(63.0, 127.0, 63.0);\n let qo = quant_codes(po, pmax);\n let qh = quant_codes(ph, pmax);\n let qv = quant_codes(pv, pmax);\n // Residual of the plane the hardware will ACTUALLY decode \u2014 the\n // quantised, clamped corners \u2014 via the normal-equation identity\n // \u03A3||p \u2212 f||\xB2 = \u03A3||p||\xB2 \u2212 2\xB7\u03B8\xB7rhs + \u03B8\u1D40G\u03B8 (G is the constant Gram matrix\n // of the fixed sample positions). Estimating with the CONTINUOUS corners\n // instead is blind to corner clamping and mis-picks planar on steep\n // gradients (a 1.4-normalised-SSE easy-block artifact on the colour\n // card). Only decode's floor-rounding stays unmodelled (\u2264 ~12 SSE).\n let shl = vec3<u32>(2u, 1u, 2u);\n let shr = vec3<u32>(4u, 6u, 4u);\n let eo = vec3<f32>((qo << shl) | (qo >> shr));\n let eh = vec3<f32>((qh << shl) | (qh >> shr));\n let ev = vec3<f32>((qv << shl) | (qv >> shr));\n let gram = 3.5 * (eo * eo + eh * eh + ev * ev) + 0.5 * eo * eh + 0.5 * eo * ev + 4.5 * eh * ev;\n let planar_est = sqtotal - 2.0 * (dot(eo, rA) + dot(eh, rB) + dot(ev, rC)) +\n dot(gram, vec3<f32>(1.0)) + PLANAR_FUDGE;\n\n // ------------------------------------------------------------ packing --\n var hi: u32;\n var lo: u32;\n if (best_est <= planar_est) {\n if (bdiff) {\n let d = vec3<u32>(vec3<i32>(codes1) - vec3<i32>(codes0)) & vec3<u32>(7u);\n hi = (codes0.r << 27u) | (d.r << 24u) | (codes0.g << 19u) | (d.g << 16u) | (codes0.b << 11u) | (d.b << 8u)\n | (t0 << 5u) | (t1 << 2u) | 2u | bflip;\n } else {\n hi = (codes0.r << 28u) | (codes1.r << 24u) | (codes0.g << 20u) | (codes1.g << 16u) | (codes0.b << 12u) | (codes1.b << 8u)\n | (t0 << 5u) | (t1 << 2u) | bflip;\n }\n lo = 0u;\n for (var sb: u32 = 0u; sb < 2u; sb = sb + 1u) {\n let indices = select(fit0, fit1, sb == 1u);\n for (var i: u32 = 0u; i < 8u; i = i + 1u) {\n let k = texel_of(bflip, sb, i);\n let wire = (k & 3u) * 4u + (k >> 2u);\n let idx = (indices >> (i * 2u)) & 3u;\n lo = lo | ((idx & 1u) << wire) | ((idx >> 1u) << (16u + wire));\n }\n }\n } else {\n let ro = qo.r; let go = qo.g; let bo = qo.b;\n let rh = qh.r; let gh = qh.g; let bh = qh.b;\n let rv = qv.r; let gv = qv.g; let bv = qv.b;\n let r_sum = i32(ro >> 2u) + signed3(((ro & 3u) << 1u) | (go >> 6u));\n let r_fix = select(0u, 1u, r_sum < 0);\n let g_sum = i32((go >> 2u) & 15u) + signed3(((go & 3u) << 1u) | (bo >> 5u));\n let g_fix = select(0u, 1u, g_sum < 0);\n let p = (bo >> 3u) & 3u;\n let q = (bo >> 1u) & 3u;\n let b_fix3 = select(0u, 7u, p + q >= 4u);\n let b_fix1 = select(1u, 0u, p + q >= 4u);\n hi = (r_fix << 31u) | (ro << 25u) | ((go >> 6u) << 24u) | (g_fix << 23u) | ((go & 63u) << 17u)\n | ((bo >> 5u) << 16u) | (b_fix3 << 13u) | (((bo >> 3u) & 3u) << 11u) | (b_fix1 << 10u)\n | ((bo & 7u) << 7u) | ((rh >> 1u) << 2u) | 2u | (rh & 1u);\n lo = (gh << 25u) | (bh << 19u) | (rv << 13u) | (gv << 6u) | bv;\n }\n\n let out = block_index * 2u;\n dst[out] = bswap(hi);\n dst[out + 1u] = bswap(lo);\n}\n";
1909
1859
 
1910
1860
  // src/ETC2Encoder.ts
1911
1861
  var ETC2Encoder = class extends Encoder {
@@ -2492,6 +2442,18 @@ async function generateGpuMipChain(device, source, { flipY = false } = {}) {
2492
2442
  }
2493
2443
 
2494
2444
  // src/svg.ts
2445
+ function isSvgMarkup(source) {
2446
+ return source.trimStart().startsWith("<");
2447
+ }
2448
+ function hasSvgExtension(url) {
2449
+ return /\.svg$/i.test(url.split(/[?#]/, 1)[0]);
2450
+ }
2451
+ function isSvgBlob(blob) {
2452
+ if (blob.type) {
2453
+ return blob.type.split(";", 1)[0].trim().toLowerCase() === "image/svg+xml";
2454
+ }
2455
+ return typeof File !== "undefined" && blob instanceof File && hasSvgExtension(blob.name);
2456
+ }
2495
2457
  var ROOT_TAG_RE = /<svg(?=[\s/>])[^>]*>/;
2496
2458
  function getAttr(tag, name) {
2497
2459
  const m = new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`).exec(tag);
@@ -2612,6 +2574,482 @@ async function rasterizeSvg(source, options = {}) {
2612
2574
  URL.revokeObjectURL(url);
2613
2575
  }
2614
2576
  }
2577
+
2578
+ // src/transcodeCache.ts
2579
+ var DEFAULT_LIMIT = 256 * 1024 * 1024;
2580
+ var maxBytes = DEFAULT_LIMIT;
2581
+ var totalBytes = 0;
2582
+ var entries = /* @__PURE__ */ new Map();
2583
+ async function sha256Hex(data) {
2584
+ const digest = await crypto.subtle.digest("SHA-256", data);
2585
+ let hex = "";
2586
+ for (const b of new Uint8Array(digest)) hex += b.toString(16).padStart(2, "0");
2587
+ return hex;
2588
+ }
2589
+ async function sourceIdentity(source, cacheKey) {
2590
+ if (cacheKey) return `k:${cacheKey}`;
2591
+ const canHash = typeof crypto !== "undefined" && !!crypto.subtle;
2592
+ if (typeof source === "string") {
2593
+ if (source.length > 1024 || /^data:/i.test(source) || /^\s*</.test(source)) {
2594
+ return canHash ? `s:${await sha256Hex(new TextEncoder().encode(source))}` : null;
2595
+ }
2596
+ return `u:${source}`;
2597
+ }
2598
+ if (typeof Blob !== "undefined" && source instanceof Blob) {
2599
+ return canHash ? `b:${await sha256Hex(await source.arrayBuffer())}` : null;
2600
+ }
2601
+ return null;
2602
+ }
2603
+ async function buildTranscodeKey(source, cacheKey, fp) {
2604
+ if (maxBytes <= 0) return null;
2605
+ const id = await sourceIdentity(source, cacheKey);
2606
+ if (!id) return null;
2607
+ const fingerprint = [
2608
+ fp.format,
2609
+ fp.colorSpace,
2610
+ fp.flipY ? "flip" : "noflip",
2611
+ fp.mipmaps ? "mips" : "nomips",
2612
+ fp.svgSize === void 0 ? "" : JSON.stringify(fp.svgSize)
2613
+ ].join("|");
2614
+ return `${fingerprint}\0${id}`;
2615
+ }
2616
+ function readTranscodeCache(key) {
2617
+ const hit = entries.get(key);
2618
+ if (!hit) return null;
2619
+ entries.delete(key);
2620
+ entries.set(key, hit);
2621
+ return hit.entry;
2622
+ }
2623
+ function writeTranscodeCache(key, entry) {
2624
+ const bytes = entry.levels.reduce((sum, l) => sum + l.data.byteLength, 0);
2625
+ if (bytes > maxBytes) return;
2626
+ const prev = entries.get(key);
2627
+ if (prev) {
2628
+ totalBytes -= prev.bytes;
2629
+ entries.delete(key);
2630
+ }
2631
+ entries.set(key, { entry, bytes });
2632
+ totalBytes += bytes;
2633
+ evictToLimit();
2634
+ }
2635
+ function evictToLimit() {
2636
+ for (const [key, value] of entries) {
2637
+ if (totalBytes <= maxBytes) break;
2638
+ entries.delete(key);
2639
+ totalBytes -= value.bytes;
2640
+ }
2641
+ }
2642
+ function setTranscodeCacheLimit(bytes) {
2643
+ maxBytes = Math.max(0, bytes);
2644
+ evictToLimit();
2645
+ }
2646
+ function clearTranscodeCache() {
2647
+ entries.clear();
2648
+ totalBytes = 0;
2649
+ }
2650
+
2651
+ // src/compressTexture.ts
2652
+ var sharedGpuPromise = null;
2653
+ var SHARED_DEVICE_FEATURES = [
2654
+ "texture-compression-bc",
2655
+ "texture-compression-astc",
2656
+ "texture-compression-etc2",
2657
+ "shader-f16",
2658
+ "timestamp-query"
2659
+ ];
2660
+ async function createSharedGpu() {
2661
+ const adapter = await navigator.gpu.requestAdapter();
2662
+ if (!adapter) return null;
2663
+ const requiredFeatures = SHARED_DEVICE_FEATURES.filter((f) => adapter.features.has(f));
2664
+ const device = await adapter.requestDevice({ requiredFeatures });
2665
+ return { adapter, device, encoders: /* @__PURE__ */ new Map() };
2666
+ }
2667
+ function getSharedGpu() {
2668
+ if (!sharedGpuPromise) {
2669
+ const p = createSharedGpu();
2670
+ sharedGpuPromise = p;
2671
+ p.then((shared) => {
2672
+ if (!shared) return;
2673
+ void shared.device.lost.then(() => {
2674
+ shared.encoders.forEach((encoder) => encoder.destroy());
2675
+ shared.encoders.clear();
2676
+ if (sharedGpuPromise === p) sharedGpuPromise = null;
2677
+ });
2678
+ }).catch(() => {
2679
+ if (sharedGpuPromise === p) sharedGpuPromise = null;
2680
+ });
2681
+ }
2682
+ return sharedGpuPromise;
2683
+ }
2684
+ function releaseSharedGpuResources() {
2685
+ const p = sharedGpuPromise;
2686
+ sharedGpuPromise = null;
2687
+ void p?.then((shared) => {
2688
+ if (!shared) return;
2689
+ shared.encoders.forEach((encoder) => encoder.destroy());
2690
+ shared.encoders.clear();
2691
+ shared.device.destroy();
2692
+ }).catch(() => {
2693
+ });
2694
+ }
2695
+ async function sourceToBitmap(source, svgSize) {
2696
+ const opts = {
2697
+ colorSpaceConversion: "none",
2698
+ premultiplyAlpha: "none"
2699
+ };
2700
+ if (typeof source === "string") {
2701
+ if (isSvgMarkup(source)) {
2702
+ return rasterizeSvg(source, { size: svgSize });
2703
+ }
2704
+ if (/^data:/i.test(source)) {
2705
+ const blob2 = dataUrlToBlob(source);
2706
+ if (isSvgBlob(blob2)) {
2707
+ return rasterizeSvg(blob2, { size: svgSize });
2708
+ }
2709
+ return createImageBitmap(blob2, opts);
2710
+ }
2711
+ const resp = await fetch(source);
2712
+ if (!resp.ok) {
2713
+ throw new Error(`compressTexture: fetch ${source} failed (${resp.status})`);
2714
+ }
2715
+ const blob = await resp.blob();
2716
+ if (isSvgBlob(blob) || !isImageMimeType(blob.type) && hasSvgExtension(source)) {
2717
+ return rasterizeSvg(blob, { size: svgSize });
2718
+ }
2719
+ return createImageBitmap(blob, opts);
2720
+ }
2721
+ if (source instanceof Blob) {
2722
+ if (isSvgBlob(source)) {
2723
+ return rasterizeSvg(source, { size: svgSize });
2724
+ }
2725
+ return createImageBitmap(source, opts);
2726
+ }
2727
+ if (source instanceof ImageBitmap) {
2728
+ return source;
2729
+ }
2730
+ if (typeof HTMLImageElement !== "undefined" && source instanceof HTMLImageElement) {
2731
+ const src = source.currentSrc || source.src;
2732
+ if (src && (hasSvgExtension(src) || /^data:image\/svg\+xml/i.test(src))) {
2733
+ const resp = await fetch(src);
2734
+ if (!resp.ok) {
2735
+ throw new Error(`compressTexture: fetch ${src} failed (${resp.status})`);
2736
+ }
2737
+ return rasterizeSvg(await resp.blob(), { size: svgSize });
2738
+ }
2739
+ }
2740
+ return createImageBitmap(source, opts);
2741
+ }
2742
+ function isImageMimeType(type) {
2743
+ return /^image\//i.test(type) && !/svg/i.test(type);
2744
+ }
2745
+ function dataUrlToBlob(url) {
2746
+ const comma = url.indexOf(",");
2747
+ if (comma < 0) {
2748
+ throw new Error("compressTexture: malformed data: URL (no comma)");
2749
+ }
2750
+ const header = url.slice(5, comma);
2751
+ const isBase64 = /;base64$/i.test(header);
2752
+ const type = header.replace(/;base64$/i, "");
2753
+ if (!isBase64) {
2754
+ return new Blob([decodeURIComponent(url.slice(comma + 1))], { type });
2755
+ }
2756
+ const payload = url.slice(comma + 1);
2757
+ const fromBase64 = Uint8Array.fromBase64;
2758
+ if (fromBase64) {
2759
+ return new Blob([fromBase64(payload)], { type });
2760
+ }
2761
+ const bin = atob(payload);
2762
+ const bytes = new Uint8Array(bin.length);
2763
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
2764
+ return new Blob([bytes], { type });
2765
+ }
2766
+ function bitmapToMipLevel(bitmap, flipY) {
2767
+ const w = bitmap.width, h = bitmap.height;
2768
+ const canvas = typeof OffscreenCanvas !== "undefined" ? new OffscreenCanvas(w, h) : Object.assign(document.createElement("canvas"), { width: w, height: h });
2769
+ const ctx = canvas.getContext("2d", { willReadFrequently: true });
2770
+ if (!ctx) {
2771
+ throw new Error("compressTexture: no 2D context available for mip generation");
2772
+ }
2773
+ if (flipY) {
2774
+ ctx.translate(0, h);
2775
+ ctx.scale(1, -1);
2776
+ }
2777
+ ctx.drawImage(bitmap, 0, 0);
2778
+ const imageData = ctx.getImageData(0, 0, w, h);
2779
+ return { data: imageData.data, width: w, height: h };
2780
+ }
2781
+ function mipLevelToImageData(level) {
2782
+ return new ImageData(level.data, level.width, level.height);
2783
+ }
2784
+ async function compressTextureToBytes(source, options = {}) {
2785
+ const {
2786
+ hint = "color",
2787
+ preferredFormat,
2788
+ quality = "high",
2789
+ colorSpace = "srgb",
2790
+ svgSize,
2791
+ flipY = true,
2792
+ mipmaps = false,
2793
+ cache = false,
2794
+ cacheKey,
2795
+ device: providedDevice,
2796
+ adapter: providedAdapter
2797
+ } = options;
2798
+ const t0 = performance.now();
2799
+ const gpu = await resolveWebGPU();
2800
+ const gl = gpu ? null : resolveWebGL();
2801
+ const activeFormat = gpu?.selection.format ?? gl?.selection.format ?? null;
2802
+ let transcodeKey = null;
2803
+ if (cache && activeFormat) {
2804
+ transcodeKey = await buildTranscodeKey(source, cacheKey, {
2805
+ format: activeFormat,
2806
+ colorSpace,
2807
+ flipY,
2808
+ mipmaps,
2809
+ svgSize
2810
+ });
2811
+ if (transcodeKey) {
2812
+ const hit = readTranscodeCache(transcodeKey);
2813
+ if (hit) {
2814
+ return {
2815
+ levels: hit.levels,
2816
+ fallbackBitmap: null,
2817
+ format: hit.format,
2818
+ fallbackUncompressed: false,
2819
+ backend: gpu ? "webgpu" : "webgl",
2820
+ astcNormalRemap: (gpu ?? gl).selection.astcNormalRemap,
2821
+ width: hit.width,
2822
+ height: hit.height,
2823
+ mipLevels: hit.levels.length,
2824
+ encodeMs: 0,
2825
+ decodeMs: 0,
2826
+ totalMs: performance.now() - t0,
2827
+ cacheHit: true
2828
+ };
2829
+ }
2830
+ }
2831
+ }
2832
+ const tDecode = performance.now();
2833
+ const bitmap = await sourceToBitmap(source, svgSize);
2834
+ const decodeMs = performance.now() - tDecode;
2835
+ if (gpu) return encodeViaWebGPU(gpu);
2836
+ const viaWebGL = gl ? encodeViaWebGL(gl) : null;
2837
+ if (viaWebGL) return viaWebGL;
2838
+ console.warn(
2839
+ "[compressTextureToBytes] No compressed path available (WebGPU and WebGL2 both lack a usable compressed-texture format); returning uncompressed RGBA8."
2840
+ );
2841
+ return {
2842
+ levels: null,
2843
+ fallbackBitmap: bitmap,
2844
+ format: null,
2845
+ fallbackUncompressed: true,
2846
+ backend: "none",
2847
+ astcNormalRemap: false,
2848
+ width: bitmap.width,
2849
+ height: bitmap.height,
2850
+ mipLevels: 1,
2851
+ encodeMs: 0,
2852
+ decodeMs,
2853
+ totalMs: performance.now() - t0,
2854
+ cacheHit: false
2855
+ };
2856
+ async function resolveWebGPU() {
2857
+ if (!("gpu" in navigator)) return null;
2858
+ let shared = null;
2859
+ let adapter;
2860
+ if (providedAdapter) {
2861
+ adapter = providedAdapter;
2862
+ } else if (providedDevice) {
2863
+ adapter = await navigator.gpu.requestAdapter();
2864
+ } else {
2865
+ shared = await getSharedGpu();
2866
+ adapter = shared?.adapter ?? null;
2867
+ }
2868
+ if (!adapter) return null;
2869
+ const selection = selectFormat(adapter, hint, { colorSpace, preferredFormat, quality });
2870
+ if (!selection.format || !selection.encoderClass) return null;
2871
+ return {
2872
+ adapter,
2873
+ shared,
2874
+ selection: { ...selection, format: selection.format, encoderClass: selection.encoderClass }
2875
+ };
2876
+ }
2877
+ async function encodeViaWebGPU({ adapter, shared, selection }) {
2878
+ const EncoderCtor = selection.encoderClass;
2879
+ let encoder;
2880
+ let sharedEncoder = false;
2881
+ if (providedDevice) {
2882
+ encoder = new EncoderCtor({ device: providedDevice, adapter, ownsDevice: false });
2883
+ } else if (shared) {
2884
+ sharedEncoder = true;
2885
+ let cached = shared.encoders.get(EncoderCtor);
2886
+ if (!cached) {
2887
+ cached = new EncoderCtor({ device: shared.device, adapter: shared.adapter, ownsDevice: false });
2888
+ shared.encoders.set(EncoderCtor, cached);
2889
+ }
2890
+ encoder = cached;
2891
+ } else {
2892
+ encoder = await EncoderCtor.create();
2893
+ }
2894
+ const destroyEncoder = sharedEncoder ? () => {
2895
+ } : () => encoder.destroy();
2896
+ try {
2897
+ const needsWriteTexture = needsWriteTextureWorkaround(adapter);
2898
+ if (!mipmaps) {
2899
+ let bytes;
2900
+ if (needsWriteTexture) {
2901
+ const level0 = bitmapToMipLevel(bitmap, flipY);
2902
+ const imageData = mipLevelToImageData(level0);
2903
+ bytes = await encoder.encodeToBytes(imageData);
2904
+ } else {
2905
+ bytes = await encoder.encodeToBytes(bitmap, { flipY });
2906
+ }
2907
+ if (transcodeKey) {
2908
+ writeTranscodeCache(transcodeKey, {
2909
+ format: selection.format,
2910
+ width: bytes.width,
2911
+ height: bytes.height,
2912
+ levels: [bytes]
2913
+ });
2914
+ }
2915
+ destroyEncoder();
2916
+ return {
2917
+ levels: [bytes],
2918
+ fallbackBitmap: null,
2919
+ format: selection.format,
2920
+ fallbackUncompressed: false,
2921
+ backend: "webgpu",
2922
+ astcNormalRemap: selection.astcNormalRemap,
2923
+ width: bytes.width,
2924
+ height: bytes.height,
2925
+ mipLevels: 1,
2926
+ encodeMs: bytes.encodeMs,
2927
+ decodeMs,
2928
+ totalMs: performance.now() - t0,
2929
+ cacheHit: false
2930
+ };
2931
+ }
2932
+ let chainResult;
2933
+ if (needsWriteTexture) {
2934
+ const level0 = bitmapToMipLevel(bitmap, flipY);
2935
+ chainResult = await encoder.encodeMipChainToBytes(generateMipChain(level0).map(padToBlockMultiple));
2936
+ } else {
2937
+ const chainTex = await generateGpuMipChain(encoder.device, bitmap, { flipY });
2938
+ try {
2939
+ chainResult = await encoder.encodeMipChainFromTexture(chainTex);
2940
+ } finally {
2941
+ chainTex.destroy();
2942
+ }
2943
+ }
2944
+ const { levels, encodeMs } = chainResult;
2945
+ if (transcodeKey) {
2946
+ writeTranscodeCache(transcodeKey, {
2947
+ format: selection.format,
2948
+ width: bitmap.width,
2949
+ height: bitmap.height,
2950
+ levels
2951
+ });
2952
+ }
2953
+ destroyEncoder();
2954
+ return {
2955
+ levels,
2956
+ fallbackBitmap: null,
2957
+ format: selection.format,
2958
+ fallbackUncompressed: false,
2959
+ backend: "webgpu",
2960
+ astcNormalRemap: selection.astcNormalRemap,
2961
+ width: bitmap.width,
2962
+ height: bitmap.height,
2963
+ mipLevels: levels.length,
2964
+ encodeMs,
2965
+ decodeMs,
2966
+ totalMs: performance.now() - t0,
2967
+ cacheHit: false
2968
+ };
2969
+ } catch (e) {
2970
+ destroyEncoder();
2971
+ throw e;
2972
+ }
2973
+ }
2974
+ function resolveWebGL() {
2975
+ const gl2 = getSharedWebGLContext();
2976
+ if (!gl2) return null;
2977
+ const caps = detectWebGLCapabilities(gl2);
2978
+ const selection = selectWebGLFormat(caps, hint, { colorSpace, preferredFormat, quality });
2979
+ if (!selection.format || !selection.encoderClass) return null;
2980
+ return { gl: gl2, selection: { ...selection, format: selection.format, encoderClass: selection.encoderClass } };
2981
+ }
2982
+ function encodeViaWebGL({ gl: gl2, selection }) {
2983
+ const encoder = selection.encoderClass.create(gl2);
2984
+ try {
2985
+ if (!mipmaps) {
2986
+ const bytes = encoder.encodeToBytes(bitmap, { flipY });
2987
+ if (transcodeKey) {
2988
+ writeTranscodeCache(transcodeKey, {
2989
+ format: selection.format,
2990
+ width: bytes.width,
2991
+ height: bytes.height,
2992
+ levels: [bytes]
2993
+ });
2994
+ }
2995
+ encoder.destroy();
2996
+ return {
2997
+ levels: [bytes],
2998
+ fallbackBitmap: null,
2999
+ format: selection.format,
3000
+ fallbackUncompressed: false,
3001
+ backend: "webgl",
3002
+ astcNormalRemap: selection.astcNormalRemap,
3003
+ width: bytes.width,
3004
+ height: bytes.height,
3005
+ mipLevels: 1,
3006
+ encodeMs: bytes.encodeMs,
3007
+ decodeMs,
3008
+ totalMs: performance.now() - t0,
3009
+ cacheHit: false
3010
+ };
3011
+ }
3012
+ const level0 = bitmapToMipLevel(bitmap, flipY);
3013
+ const chain = generateMipChain(level0);
3014
+ const encodedLevels = [];
3015
+ let totalEncodeMs = 0;
3016
+ for (const level of chain) {
3017
+ const padded = padToBlockMultiple(level);
3018
+ const bytes = encoder.encodeToBytes(padded);
3019
+ encodedLevels.push(bytes);
3020
+ totalEncodeMs += bytes.encodeMs;
3021
+ }
3022
+ if (transcodeKey) {
3023
+ writeTranscodeCache(transcodeKey, {
3024
+ format: selection.format,
3025
+ width: level0.width,
3026
+ height: level0.height,
3027
+ levels: encodedLevels
3028
+ });
3029
+ }
3030
+ encoder.destroy();
3031
+ return {
3032
+ levels: encodedLevels,
3033
+ fallbackBitmap: null,
3034
+ format: selection.format,
3035
+ fallbackUncompressed: false,
3036
+ backend: "webgl",
3037
+ astcNormalRemap: selection.astcNormalRemap,
3038
+ width: level0.width,
3039
+ height: level0.height,
3040
+ mipLevels: encodedLevels.length,
3041
+ encodeMs: totalEncodeMs,
3042
+ decodeMs,
3043
+ totalMs: performance.now() - t0,
3044
+ cacheHit: false
3045
+ };
3046
+ } catch (e) {
3047
+ encoder.destroy();
3048
+ console.warn("[compressTextureToBytes] WebGL fallback encode failed; returning uncompressed RGBA8.", e);
3049
+ return null;
3050
+ }
3051
+ }
3052
+ }
2615
3053
  export {
2616
3054
  ASTC4x4Encoder,
2617
3055
  ASTC4x4WebGLEncoder,
@@ -2626,6 +3064,8 @@ export {
2626
3064
  TextureFormat,
2627
3065
  WebGLBlockEncoder,
2628
3066
  WebGPUFeature,
3067
+ clearTranscodeCache,
3068
+ compressTextureToBytes,
2629
3069
  createWebGLContext,
2630
3070
  detectCapabilities,
2631
3071
  detectWebGLCapabilities,
@@ -2636,6 +3076,8 @@ export {
2636
3076
  isWebGLAvailable,
2637
3077
  padToBlockMultiple,
2638
3078
  rasterizeSvg,
3079
+ releaseSharedGpuResources,
2639
3080
  selectFormat,
2640
- selectWebGLFormat
3081
+ selectWebGLFormat,
3082
+ setTranscodeCacheLimit
2641
3083
  };