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/three.js CHANGED
@@ -72,6 +72,22 @@ function uploadSourceTexture(device, srcTex, source, width, height, flipY) {
72
72
 
73
73
  // src/Encoder.ts
74
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
+ }
75
91
  var Encoder = class {
76
92
  /**
77
93
  * Subclasses set this to the WebGPU feature string the output texture
@@ -144,10 +160,11 @@ var Encoder = class {
144
160
  _cachedSrcSource = null;
145
161
  _cachedSrcFlipY = false;
146
162
  _cachedDst = null;
147
- _cachedStaging = null;
163
+ // One MAP_READ staging buffer and bind group per row band (see bandRows).
164
+ _cachedStagings = [];
148
165
  _cachedParams = null;
149
- _lastParams = null;
150
- _cachedBindGroup = null;
166
+ _lastParamsKey = null;
167
+ _cachedBindGroups = [];
151
168
  _cachedPrepPlanes = null;
152
169
  _cachedPrepBindGroup = null;
153
170
  _resourcesBusy = false;
@@ -167,8 +184,11 @@ var Encoder = class {
167
184
  _chainPrepBindGroups = [];
168
185
  _chainParams = null;
169
186
  _chainBindGroups = [];
187
+ // Level-0 bind groups of the base-level row bands (see bandRows).
188
+ _chainBandBindGroups = [];
170
189
  _chainDst = null;
171
- _chainStaging = null;
190
+ // One MAP_READ staging buffer per chain submission (base-level band).
191
+ _chainStagings = [];
172
192
  _chainBusy = false;
173
193
  constructor({ device, adapter, ownsDevice = false, disableF16 = false }) {
174
194
  this.device = device;
@@ -212,14 +232,14 @@ var Encoder = class {
212
232
  this._cachedPrepPlanes = null;
213
233
  this._cachedPrepBindGroup = null;
214
234
  this._cachedDst?.destroy();
215
- this._cachedStaging?.destroy();
235
+ for (const st of this._cachedStagings) st.destroy();
216
236
  this._cachedParams?.destroy();
217
237
  this._cachedSrcTex = null;
218
238
  this._cachedSrcSource = null;
219
239
  this._cachedDst = null;
220
- this._cachedStaging = null;
240
+ this._cachedStagings = [];
221
241
  this._cachedParams = null;
222
- this._cachedBindGroup = null;
242
+ this._cachedBindGroups = [];
223
243
  for (const tex of this._chainTextures) tex.destroy();
224
244
  this._chainTextures = [];
225
245
  for (const planes of this._chainPrepPlanes) for (const t of planes) t.destroy();
@@ -227,11 +247,12 @@ var Encoder = class {
227
247
  this._chainPrepBindGroups = [];
228
248
  this._chainParams?.destroy();
229
249
  this._chainDst?.destroy();
230
- this._chainStaging?.destroy();
250
+ for (const st of this._chainStagings) st.destroy();
231
251
  this._chainParams = null;
232
252
  this._chainDst = null;
233
- this._chainStaging = null;
253
+ this._chainStagings = [];
234
254
  this._chainBindGroups = [];
255
+ this._chainBandBindGroups = [];
235
256
  this._chainSig = null;
236
257
  if (this.ownsDevice) this.device.destroy();
237
258
  }
@@ -318,7 +339,7 @@ var Encoder = class {
318
339
  entries: [
319
340
  { binding: 0, resource: srcView },
320
341
  { binding: 1, resource: planes[0].createView() },
321
- { binding: 2, resource: { buffer: params, offset: paramsOffset, size: 16 } },
342
+ { binding: 2, resource: { buffer: params, offset: paramsOffset, size: PARAMS_SIZE } },
322
343
  { binding: 3, resource: planes[1].createView() }
323
344
  ]
324
345
  });
@@ -362,7 +383,8 @@ var Encoder = class {
362
383
  let srcTex;
363
384
  let dstBuffer;
364
385
  let paramsBuffer;
365
- let staging;
386
+ const stagings = [];
387
+ const transientStagings = [];
366
388
  let transientPrepPlanes = null;
367
389
  try {
368
390
  let srcTexIsNew = true;
@@ -425,66 +447,82 @@ var Encoder = class {
425
447
  this._cachedDst = dstBuffer;
426
448
  }
427
449
  }
428
- if (useCache && this._cachedStaging && this._cachedStaging.size >= outByteLen) {
429
- staging = this._cachedStaging;
430
- } else {
431
- staging = device.createBuffer({
432
- label: `${this.label}-staging`,
433
- size: outByteLen,
434
- usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ
435
- });
436
- if (useCache) {
437
- this._cachedStaging?.destroy();
438
- 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
+ }
439
468
  }
469
+ stagings.push(st);
440
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}`;
441
476
  if (useCache) {
442
477
  if (!this._cachedParams) {
443
478
  this._cachedParams = device.createBuffer({
444
479
  label: `${this.label}-params`,
445
- size: 16,
480
+ size: MAX_BANDS * PARAMS_ALIGN,
446
481
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
447
482
  });
448
- this._lastParams = null;
483
+ this._lastParamsKey = null;
449
484
  }
450
485
  paramsBuffer = this._cachedParams;
451
- const lp = this._lastParams;
452
- if (!lp || lp[0] !== blocksX || lp[1] !== blocksY || lp[2] !== width || lp[3] !== height) {
453
- device.queue.writeBuffer(paramsBuffer, 0, new Uint32Array([blocksX, blocksY, width, height]));
454
- this._lastParams = [blocksX, blocksY, width, height];
486
+ if (this._lastParamsKey !== paramsKey) {
487
+ device.queue.writeBuffer(paramsBuffer, 0, paramsData);
488
+ this._lastParamsKey = paramsKey;
455
489
  }
456
490
  } else {
457
491
  paramsBuffer = device.createBuffer({
458
492
  label: `${this.label}-params`,
459
- size: 16,
493
+ size: MAX_BANDS * PARAMS_ALIGN,
460
494
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
461
495
  });
462
- device.queue.writeBuffer(paramsBuffer, 0, new Uint32Array([blocksX, blocksY, width, height]));
496
+ device.queue.writeBuffer(paramsBuffer, 0, paramsData);
463
497
  }
464
- if (useCache && (srcTexIsNew || dstIsNew || prepPlanesNew)) this._cachedBindGroup = null;
465
- let bindGroup = useCache ? this._cachedBindGroup : null;
466
- if (!bindGroup) {
467
- const entries2 = [
468
- { binding: 0, resource: prepPlanes ? prepPlanes[0].createView() : srcTex.createView() },
469
- { binding: 1, resource: { buffer: dstBuffer } },
470
- { binding: 2, resource: { buffer: paramsBuffer } }
471
- ];
472
- if (prepPlanes) {
473
- entries2.push({ binding: 3, resource: prepPlanes[1].createView() });
474
- } else if (this._usesSampler) {
475
- this._sampler ??= device.createSampler({
476
- label: `${this.label}-clamp-sampler`,
477
- addressModeU: "clamp-to-edge",
478
- 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
479
522
  });
480
- entries2.push({ binding: 3, resource: this._sampler });
523
+ if (useCache) this._cachedBindGroups[b] = bg;
481
524
  }
482
- bindGroup = device.createBindGroup({
483
- label: `${this.label}-bg`,
484
- layout: pipeline.getBindGroupLayout(0),
485
- entries: entries2
486
- });
487
- if (useCache) this._cachedBindGroup = bindGroup;
525
+ bindGroups.push(bg);
488
526
  }
489
527
  let prepBindGroup = useCache && !prepPlanesNew && !srcTexIsNew ? this._cachedPrepBindGroup : null;
490
528
  if (prepPipeline && prepPlanes && !prepBindGroup) {
@@ -492,41 +530,48 @@ var Encoder = class {
492
530
  if (useCache) this._cachedPrepBindGroup = prepBindGroup;
493
531
  }
494
532
  const timing = withGpuTime ? this._createTiming() : null;
495
- const [wgX, wgY] = this.workgroupSize;
496
533
  const t0 = performance.now();
497
- const enc = device.createCommandEncoder({ label: `${this.label}-encode` });
498
- if (prepPipeline && prepBindGroup) {
499
- const prepPass = enc.beginComputePass(
500
- timing ? { timestampWrites: { querySet: timing.querySet, beginningOfPassWriteIndex: 0 } } : void 0
501
- );
502
- prepPass.setPipeline(prepPipeline);
503
- prepPass.setBindGroup(0, prepBindGroup);
504
- const [px, py] = this.prepDispatch(blocksX, blocksY);
505
- prepPass.dispatchWorkgroups(Math.ceil(px / wgX), Math.ceil(py / wgY), 1);
506
- 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));
507
567
  }
508
- const pass = enc.beginComputePass(
509
- timing ? {
510
- timestampWrites: {
511
- querySet: timing.querySet,
512
- ...prepPipeline ? {} : { beginningOfPassWriteIndex: 0 },
513
- endOfPassWriteIndex: 1
514
- }
515
- } : void 0
516
- );
517
- pass.setPipeline(pipeline);
518
- pass.setBindGroup(0, bindGroup);
519
- pass.dispatchWorkgroups(Math.ceil(blocksX / wgX), Math.ceil(blocksY / wgY), 1);
520
- pass.end();
521
- enc.copyBufferToBuffer(dstBuffer, 0, staging, 0, outByteLen);
522
- if (timing) {
523
- enc.resolveQuerySet(timing.querySet, 0, 2, timing.resolve, 0);
524
- 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();
525
574
  }
526
- device.queue.submit([enc.finish()]);
527
- await staging.mapAsync(GPUMapMode.READ, 0, outByteLen);
528
- const data = new Uint8Array(staging.getMappedRange(0, outByteLen).slice(0));
529
- staging.unmap();
530
575
  const encodeMs = performance.now() - t0;
531
576
  const gpuMs = timing ? await this._readTimingMs(timing) : void 0;
532
577
  return { width, height, paddedWidth, paddedHeight, data, encodeMs, gpuMs };
@@ -536,7 +581,7 @@ var Encoder = class {
536
581
  } else {
537
582
  srcTex?.destroy();
538
583
  dstBuffer?.destroy();
539
- staging?.destroy();
584
+ for (const st of transientStagings) st.destroy();
540
585
  paramsBuffer?.destroy();
541
586
  if (transientPrepPlanes) for (const t of transientPrepPlanes) t.destroy();
542
587
  }
@@ -579,7 +624,7 @@ var Encoder = class {
579
624
  );
580
625
  }
581
626
  });
582
- const { geoms, byteSpan } = this._chainGeometry(levels);
627
+ const { geoms, byteSpan, bands } = this._chainGeometry(levels);
583
628
  const sig = geoms.map((g) => `${g.width}x${g.height}`).join();
584
629
  const useCache = !this._chainBusy;
585
630
  if (useCache) this._chainBusy = true;
@@ -588,8 +633,9 @@ var Encoder = class {
588
633
  let prepBindGroups;
589
634
  let params;
590
635
  let bindGroups;
636
+ let bandBindGroups;
591
637
  let dst;
592
- let staging;
638
+ const transientStagings = [];
593
639
  let transientLevelSet = false;
594
640
  try {
595
641
  let dstIsNew = true;
@@ -611,6 +657,7 @@ var Encoder = class {
611
657
  textures = this._chainTextures;
612
658
  params = this._chainParams;
613
659
  bindGroups = this._chainBindGroups;
660
+ bandBindGroups = this._chainBandBindGroups;
614
661
  prepPlaneSets = this._chainPrepPlanes;
615
662
  prepBindGroups = this._chainPrepBindGroups;
616
663
  } else {
@@ -625,16 +672,7 @@ var Encoder = class {
625
672
  })
626
673
  );
627
674
  textures = texs;
628
- params = device.createBuffer({
629
- label: `${this.label}-chain-params`,
630
- size: geoms.length * CHAIN_ALIGN,
631
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
632
- });
633
- const paramsData = new Uint32Array(geoms.length * CHAIN_ALIGN / 4);
634
- geoms.forEach((g, i) => {
635
- paramsData.set([g.blocksX, g.blocksY, g.width, g.height], i * CHAIN_ALIGN / 4);
636
- });
637
- device.queue.writeBuffer(params, 0, paramsData);
675
+ params = this._createChainParams(geoms, bands);
638
676
  if (this._usesSampler) {
639
677
  this._sampler ??= device.createSampler({
640
678
  label: `${this.label}-clamp-sampler`,
@@ -649,14 +687,15 @@ var Encoder = class {
649
687
  prepBindGroups = prepPipeline ? geoms.map(
650
688
  (g, i) => this._createPrepBindGroup(prepPipeline, texs[i].createView(), planeSets[i], paramsBuf, i * CHAIN_ALIGN)
651
689
  ) : [];
652
- bindGroups = geoms.map((g, i) => {
690
+ const makeBg = (i, slot) => {
691
+ const g = geoms[i];
653
692
  const entries2 = [
654
693
  {
655
694
  binding: 0,
656
695
  resource: prepPipeline ? planeSets[i][0].createView() : texs[i].createView()
657
696
  },
658
697
  { binding: 1, resource: { buffer: dstBuf, offset: g.dstOffset, size: g.byteLen } },
659
- { binding: 2, resource: { buffer: paramsBuf, offset: i * CHAIN_ALIGN, size: 16 } }
698
+ { binding: 2, resource: { buffer: paramsBuf, offset: slot * CHAIN_ALIGN, size: PARAMS_SIZE } }
660
699
  ];
661
700
  if (prepPipeline) {
662
701
  entries2.push({ binding: 3, resource: planeSets[i][1].createView() });
@@ -668,7 +707,9 @@ var Encoder = class {
668
707
  layout: pipeline.getBindGroupLayout(0),
669
708
  entries: entries2
670
709
  });
671
- });
710
+ };
711
+ bindGroups = geoms.map((_, i) => makeBg(i, i));
712
+ bandBindGroups = bands.map((_, b) => makeBg(0, geoms.length + b));
672
713
  if (useCache) {
673
714
  for (const tex of this._chainTextures) tex.destroy();
674
715
  for (const planes of this._chainPrepPlanes) for (const t of planes) t.destroy();
@@ -678,24 +719,12 @@ var Encoder = class {
678
719
  this._chainPrepBindGroups = prepBindGroups;
679
720
  this._chainParams = params;
680
721
  this._chainBindGroups = bindGroups;
722
+ this._chainBandBindGroups = bandBindGroups;
681
723
  this._chainSig = sig;
682
724
  } else {
683
725
  transientLevelSet = true;
684
726
  }
685
727
  }
686
- if (useCache && this._chainStaging && this._chainStaging.size >= byteSpan) {
687
- staging = this._chainStaging;
688
- } else {
689
- staging = device.createBuffer({
690
- label: `${this.label}-chain-staging`,
691
- size: byteSpan,
692
- usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ
693
- });
694
- if (useCache) {
695
- this._chainStaging?.destroy();
696
- this._chainStaging = staging;
697
- }
698
- }
699
728
  for (let i = 0; i < levels.length; i++) {
700
729
  const level = levels[i];
701
730
  device.queue.writeTexture({ texture: textures[i] }, level.data, { bytesPerRow: level.width * 4 }, [
@@ -708,9 +737,12 @@ var Encoder = class {
708
737
  pipeline,
709
738
  geoms,
710
739
  byteSpan,
740
+ bands,
711
741
  bindGroups,
742
+ bandBindGroups,
712
743
  dst,
713
- staging,
744
+ useCache,
745
+ transientStagings,
714
746
  withGpuTime,
715
747
  t0,
716
748
  prepPipeline && prepBindGroups ? { pipeline: prepPipeline, bindGroups: prepBindGroups } : null
@@ -720,8 +752,8 @@ var Encoder = class {
720
752
  this._chainBusy = false;
721
753
  } else {
722
754
  dst?.destroy();
723
- staging?.destroy();
724
755
  }
756
+ for (const st of transientStagings) st.destroy();
725
757
  if (transientLevelSet) {
726
758
  if (textures) for (const tex of textures) tex.destroy();
727
759
  if (prepPlaneSets) for (const planes of prepPlaneSets) for (const t of planes) t.destroy();
@@ -751,11 +783,11 @@ var Encoder = class {
751
783
  for (let i = 0; i < srcTex.mipLevelCount; i++) {
752
784
  dims.push({ width: Math.max(1, srcTex.width >> i), height: Math.max(1, srcTex.height >> i) });
753
785
  }
754
- const { geoms, byteSpan } = this._chainGeometry(dims);
786
+ const { geoms, byteSpan, bands } = this._chainGeometry(dims);
755
787
  const useCache = !this._chainBusy;
756
788
  if (useCache) this._chainBusy = true;
757
789
  let dst;
758
- let staging;
790
+ const transientStagings = [];
759
791
  let params;
760
792
  let planeSets = null;
761
793
  try {
@@ -773,29 +805,7 @@ var Encoder = class {
773
805
  this._chainSig = null;
774
806
  }
775
807
  }
776
- if (useCache && this._chainStaging && this._chainStaging.size >= byteSpan) {
777
- staging = this._chainStaging;
778
- } else {
779
- staging = device.createBuffer({
780
- label: `${this.label}-chain-staging`,
781
- size: byteSpan,
782
- usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ
783
- });
784
- if (useCache) {
785
- this._chainStaging?.destroy();
786
- this._chainStaging = staging;
787
- }
788
- }
789
- params = device.createBuffer({
790
- label: `${this.label}-chain-params`,
791
- size: geoms.length * CHAIN_ALIGN,
792
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
793
- });
794
- const paramsData = new Uint32Array(geoms.length * CHAIN_ALIGN / 4);
795
- geoms.forEach((g, i) => {
796
- paramsData.set([g.blocksX, g.blocksY, g.width, g.height], i * CHAIN_ALIGN / 4);
797
- });
798
- device.queue.writeBuffer(params, 0, paramsData);
808
+ params = this._createChainParams(geoms, bands);
799
809
  if (this._usesSampler) {
800
810
  this._sampler ??= device.createSampler({
801
811
  label: `${this.label}-clamp-sampler`,
@@ -816,14 +826,15 @@ var Encoder = class {
816
826
  i * CHAIN_ALIGN
817
827
  )
818
828
  ) : null;
819
- const bindGroups = geoms.map((g, i) => {
829
+ const makeBg = (i, slot) => {
830
+ const g = geoms[i];
820
831
  const entries2 = [
821
832
  {
822
833
  binding: 0,
823
834
  resource: planes ? planes[i][0].createView() : srcTex.createView({ baseMipLevel: i, mipLevelCount: 1 })
824
835
  },
825
836
  { binding: 1, resource: { buffer: dstBuf, offset: g.dstOffset, size: g.byteLen } },
826
- { binding: 2, resource: { buffer: paramsBuf, offset: i * CHAIN_ALIGN, size: 16 } }
837
+ { binding: 2, resource: { buffer: paramsBuf, offset: slot * CHAIN_ALIGN, size: PARAMS_SIZE } }
827
838
  ];
828
839
  if (planes) {
829
840
  entries2.push({ binding: 3, resource: planes[i][1].createView() });
@@ -835,14 +846,19 @@ var Encoder = class {
835
846
  layout: pipeline.getBindGroupLayout(0),
836
847
  entries: entries2
837
848
  });
838
- });
849
+ };
850
+ const bindGroups = geoms.map((_, i) => makeBg(i, i));
851
+ const bandBindGroups = bands.map((_, b) => makeBg(0, geoms.length + b));
839
852
  return await this._submitChainAndRead(
840
853
  pipeline,
841
854
  geoms,
842
855
  byteSpan,
856
+ bands,
843
857
  bindGroups,
858
+ bandBindGroups,
844
859
  dst,
845
- staging,
860
+ useCache,
861
+ transientStagings,
846
862
  withGpuTime,
847
863
  t0,
848
864
  prepPipeline && prepBindGroups ? { pipeline: prepPipeline, bindGroups: prepBindGroups } : null
@@ -852,8 +868,8 @@ var Encoder = class {
852
868
  this._chainBusy = false;
853
869
  } else {
854
870
  dst?.destroy();
855
- staging?.destroy();
856
871
  }
872
+ for (const st of transientStagings) st.destroy();
857
873
  params?.destroy();
858
874
  if (planeSets) for (const planes of planeSets) for (const t of planes) t.destroy();
859
875
  }
@@ -861,7 +877,7 @@ var Encoder = class {
861
877
  /** Block-grid geometry + packed output offsets for a chain of levels.
862
878
  * `byteSpan` is both the dst buffer size and the readback copy size (a
863
879
  * multiple of 4: byteLen is a multiple of bytesPerBlock ≥ 8, offsets are
864
- * CHAIN_ALIGN-ed). */
880
+ * CHAIN_ALIGN-ed). `bands` splits the base level into row bands. */
865
881
  _chainGeometry(dims) {
866
882
  let dstCursor = 0;
867
883
  const geoms = dims.map(({ width, height }) => {
@@ -875,60 +891,131 @@ var Encoder = class {
875
891
  return { width, height, paddedWidth, paddedHeight, blocksX, blocksY, byteLen, dstOffset };
876
892
  });
877
893
  const last = geoms[geoms.length - 1];
878
- 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
+ };
879
900
  }
880
- /** Shared chain-encode tail: one compute pass with a dispatch per level,
881
- * one submit, one staging readback sliced into per-level byte arrays. */
882
- 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) {
883
926
  const device = this.device;
884
927
  const timing = withGpuTime ? this._createTiming() : null;
885
928
  const [wgX, wgY] = this.workgroupSize;
886
- const enc = device.createCommandEncoder({ label: `${this.label}-encode-chain` });
887
- if (prep) {
888
- const prepPass = enc.beginComputePass(
889
- timing ? { timestampWrites: { querySet: timing.querySet, beginningOfPassWriteIndex: 0 } } : void 0
890
- );
891
- prepPass.setPipeline(prep.pipeline);
892
- for (let i = 0; i < geoms.length; i++) {
893
- const g = geoms[i];
894
- prepPass.setBindGroup(0, prep.bindGroups[i]);
895
- const [px, py] = this.prepDispatch(g.blocksX, g.blocksY);
896
- 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
+ }
897
950
  }
898
- prepPass.end();
899
- }
900
- const pass = enc.beginComputePass(
901
- timing ? {
902
- timestampWrites: {
903
- querySet: timing.querySet,
904
- ...prep ? {} : { beginningOfPassWriteIndex: 0 },
905
- 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);
906
968
  }
907
- } : void 0
908
- );
909
- pass.setPipeline(pipeline);
910
- for (let i = 0; i < geoms.length; i++) {
911
- const g = geoms[i];
912
- pass.setBindGroup(0, bindGroups[i]);
913
- pass.dispatchWorkgroups(Math.ceil(g.blocksX / wgX), Math.ceil(g.blocksY / wgY), 1);
914
- }
915
- pass.end();
916
- enc.copyBufferToBuffer(dst, 0, staging, 0, byteSpan);
917
- if (timing) {
918
- enc.resolveQuerySet(timing.querySet, 0, 2, timing.resolve, 0);
919
- 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));
920
995
  }
921
- device.queue.submit([enc.finish()]);
922
- await staging.mapAsync(GPUMapMode.READ, 0, byteSpan);
923
- const mapped = staging.getMappedRange(0, byteSpan);
924
996
  const out = geoms.map((g) => ({
925
997
  width: g.width,
926
998
  height: g.height,
927
999
  paddedWidth: g.paddedWidth,
928
1000
  paddedHeight: g.paddedHeight,
929
- data: new Uint8Array(mapped.slice(g.dstOffset, g.dstOffset + g.byteLen))
1001
+ data: allocPrefaulted(g.byteLen)
930
1002
  }));
931
- 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
+ }
932
1019
  const encodeMs = performance.now() - t0;
933
1020
  const gpuMs = timing ? await this._readTimingMs(timing) : void 0;
934
1021
  return { levels: out, encodeMs, gpuMs };
@@ -972,250 +1059,10 @@ var Encoder = class {
972
1059
  };
973
1060
 
974
1061
  // src/bc1.wgsl
975
- 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";
976
1063
 
977
1064
  // src/bc1_fast_f16.wgsl
978
- var bc1_fast_f16_default = `// bc1 "fast" encoder \u2014 f16 variant (requires the shader-f16 feature).
979
- //
980
- // BC1 quantises endpoints to RGB565 anyway, so the fast path needs nothing
981
- // f32 can do that f16 can't \u2014 all projection / least-squares math runs in
982
- // f16 ([0,1] domain). The algorithm is the same family as the BC7/ASTC fast
983
- // paths rather than a port of bc1.wgsl's fast branch:
984
- //
985
- // 1. principal-axis endpoint seed (covariance power-iteration; inset bbox
986
- // on degenerate blocks), inset by ~half a 565 cell (stb_dxt heuristic)
987
- // 2. quantise to 565, force 4-colour mode (c0 > c1)
988
- // 3. ONE fused pass: project every pixel onto the decoded-endpoint line
989
- // (the 4 palette entries are colinear and evenly spaced, so the nearest
990
- // entry is the rounded projection \u2014 no 4-entry search) while
991
- // accumulating the least-squares refit sums, the packed indices and the
992
- // squared error
993
- // 4. up to TWO refit rounds (mirroring the high path's iterated refits):
994
- // re-quantise the refit endpoints, reproject (indices packed on the
995
- // fly, sums re-accumulated to seed the next round), and accept each
996
- // round only if the block error decreases \u2014 flat/single-level blocks
997
- // skip these passes entirely
998
- //
999
- // vs the pre-projection fast branch (build palette + full 4-entry search \xD7 3
1000
- // passes + refit sums pass) this does roughly half the ALU per block. The
1001
- // 565 decode uses exact integer math, so the palette base points are exact.
1002
- //
1003
- // The host selects this module only when the device reports shader-f16,
1004
- // falling back to bc1.wgsl otherwise. "high" never uses this.
1005
- enable f16;
1006
- struct Params { blocks_x: u32, blocks_y: u32, width: u32, height: u32, };
1007
- @group(0) @binding(0) var src_tex: texture_2d<f32>;
1008
- @group(0) @binding(1) var<storage, read_write> dst: array<u32>;
1009
- @group(0) @binding(2) var<uniform> params: Params;
1010
- alias h = f16;
1011
- alias h3 = vec3<f16>;
1012
-
1013
- fn to565(c: h3) -> u32 {
1014
- let r = u32(clamp(floor(c.r * h(31.0) + h(0.5)), h(0.0), h(31.0)));
1015
- let g = u32(clamp(floor(c.g * h(63.0) + h(0.5)), h(0.0), h(63.0)));
1016
- let b = u32(clamp(floor(c.b * h(31.0) + h(0.5)), h(0.0), h(31.0)));
1017
- return (r << 11u) | (g << 5u) | b;
1018
- }
1019
-
1020
- // Decode a 565 endpoint to [0,1]: (x*527+23)>>6 (6-bit: 259/33) \u2014
1021
- // round-to-nearest scaling, matching bc1_ref.ts / bc1.wgsl and typical
1022
- // hardware decoders. Exact in u32 integer math (f16 could not evaluate the
1023
- // products exactly).
1024
- fn from565(c: u32) -> h3 {
1025
- let r = (c >> 11u) & 31u;
1026
- let g = (c >> 5u) & 63u;
1027
- let b = c & 31u;
1028
- let r8 = (r * 527u + 23u) >> 6u;
1029
- let g8 = (g * 259u + 33u) >> 6u;
1030
- let b8 = (b * 527u + 23u) >> 6u;
1031
- return h3(vec3<f32>(vec3<u32>(r8, g8, b8))) * h(1.0 / 255.0);
1032
- }
1033
-
1034
- // Force 4-colour mode: c0 > c1 strictly.
1035
- fn order565(a: u32, b: u32) -> vec2<u32> {
1036
- var c0 = a; var c1 = b;
1037
- if (c0 == c1) {
1038
- if (c1 > 0u) { c1 = c1 - 1u; } else { c0 = c0 + 1u; }
1039
- } else if (c0 < c1) {
1040
- let t = c0; c0 = c1; c1 = t;
1041
- }
1042
- return vec2<u32>(c0, c1);
1043
- }
1044
-
1045
- // One projection pass against the decoded endpoints of (c0,c1): the packed
1046
- // 2-bit indices, the block's squared error, and the LSQ normal-equation sums
1047
- // of the resulting assignment \u2014 so an accepted refit can seed the next
1048
- // round. Levels s run 0..3 along p0\u2192p1 (palette = p0, p0+\u2153d, p0+\u2154d, p1 \u2014
1049
- // colinear, evenly spaced, so rounding the projection IS the nearest-entry
1050
- // search). Level \u2192 BC1 index: 0\u21920 (c0), 1\u21922 (\u2154c0+\u2153c1), 2\u21923, 3\u21921 (c1); as a
1051
- // packed LUT: (0x78 >> 2L) & 3.
1052
- struct Proj {
1053
- indices: u32,
1054
- err: h,
1055
- sAA: h, sBB: h, sAB: h,
1056
- sAV: h3, sBV: h3,
1057
- s_min: h, s_max: h,
1058
- };
1059
- fn project_stats(pix: ptr<function, array<h3, 16>>, c0: u32, c1: u32) -> Proj {
1060
- var out: Proj;
1061
- out.indices = 0u;
1062
- out.err = h(0.0);
1063
- out.sAA = h(0.0); out.sBB = h(0.0); out.sAB = h(0.0);
1064
- out.sAV = h3(0.0); out.sBV = h3(0.0);
1065
- out.s_min = h(3.0); out.s_max = h(0.0);
1066
- let p0 = from565(c0);
1067
- let p1 = from565(c1);
1068
- let dir = p1 - p0;
1069
- let dd = dot(dir, dir);
1070
- if (dd == h(0.0)) {
1071
- // Unreachable for distinct 565 codes (the decode is injective); kept so
1072
- // a degenerate call still returns a consistent error.
1073
- out.s_min = h(0.0);
1074
- for (var k: u32 = 0u; k < 16u; k = k + 1u) {
1075
- let e = (*pix)[k] - p0;
1076
- out.err = out.err + dot(e, e);
1077
- }
1078
- return out;
1079
- }
1080
- let inv = h(3.0) / dd;
1081
- for (var k: u32 = 0u; k < 16u; k = k + 1u) {
1082
- let v = (*pix)[k];
1083
- let s = clamp(floor(dot(v - p0, dir) * inv + h(0.5)), h(0.0), h(3.0));
1084
- out.s_min = min(out.s_min, s); out.s_max = max(out.s_max, s);
1085
- let b = s * h(1.0 / 3.0); let a = h(1.0) - b;
1086
- out.sAA = out.sAA + a * a; out.sBB = out.sBB + b * b; out.sAB = out.sAB + a * b;
1087
- out.sAV = out.sAV + a * v; out.sBV = out.sBV + b * v;
1088
- let e = v - (p0 + b * dir);
1089
- out.err = out.err + dot(e, e);
1090
- out.indices = out.indices | (((0x78u >> (u32(s) * 2u)) & 3u) << (k * 2u));
1091
- }
1092
- return out;
1093
- }
1094
-
1095
- @compute @workgroup_size(8, 8, 1)
1096
- fn encode(@builtin(global_invocation_id) gid: vec3<u32>) {
1097
- if (gid.x >= params.blocks_x || gid.y >= params.blocks_y) { return; }
1098
- let bi = gid.y * params.blocks_x + gid.x;
1099
- let base = vec2<i32>(i32(gid.x) * 4, i32(gid.y) * 4);
1100
- let mx = vec2<i32>(i32(params.width) - 1, i32(params.height) - 1);
1101
-
1102
- var pix: array<h3, 16>;
1103
- var mn = h3(1.0);
1104
- var mxv = h3(0.0);
1105
- var mean = h3(0.0);
1106
- var gd = h(0.0);
1107
- for (var i: u32 = 0u; i < 16u; i = i + 1u) {
1108
- let p = clamp(base + vec2<i32>(i32(i & 3u), i32(i >> 2u)), vec2<i32>(0), mx);
1109
- let px = h3(textureLoad(src_tex, p, 0).rgb);
1110
- pix[i] = px; mn = min(mn, px); mxv = max(mxv, px);
1111
- mean = mean + px;
1112
- gd = max(gd, max(abs(px.x - px.y), abs(px.x - px.z)));
1113
- }
1114
- mean = mean * h(1.0 / 16.0);
1115
- // Exactly-gray blocks free the refit from the bbox clamp below: a gray
1116
- // block has no hue to bend (the clamp's whole purpose), and on smooth
1117
- // gradients the LSQ optimum often lies OUTSIDE the data range \u2014 endpoints
1118
- // spread wider than the block so the 1/3-2/3 interpolants land on the
1119
- // values. Same rationale as the BC5 scalar channels (+0.32 dB there).
1120
- let gray = gd == h(0.0);
1121
- let lim_lo = select(mn, h3(0.0), gray);
1122
- let lim_hi = select(mxv, h3(1.0), gray);
1123
-
1124
- // Seed endpoints from the block's principal colour axis (covariance
1125
- // power-iteration, seeded with the bbox diagonal \u2014 same family as the
1126
- // 'high' path). The bbox diagonal is sign-blind: on anti-correlated
1127
- // channels (normal maps, hue edges) it points across the data instead of
1128
- // along it, the projection indices come out garbage, and the LSQ refit \u2014
1129
- // which fits endpoints GIVEN those indices \u2014 can't recover. Deviations are
1130
- // pre-scaled \xD716 so covariance entries for shallow blocks stay in f16's
1131
- // normal range (span ~1/255 \u2192 d\xB2 \u2248 1e-3) while full-range sums stay \u22644096;
1132
- // the iteration renormalises by the max component (a plain length() of the
1133
- // matvec output could overflow f16), so only the direction survives \u2014 the
1134
- // \xD7256 covariance scale is irrelevant.
1135
- var seed_hi: h3;
1136
- var seed_lo: h3;
1137
- var c0v = h3(0.0);
1138
- var c1v = h3(0.0);
1139
- var c2v = h3(0.0);
1140
- for (var k: u32 = 0u; k < 16u; k = k + 1u) {
1141
- let d = (pix[k] - mean) * h(16.0);
1142
- c0v = c0v + d.x * d;
1143
- c1v = c1v + d.y * d;
1144
- c2v = c2v + d.z * d;
1145
- }
1146
- var axis = mxv - mn;
1147
- var axis_ok = true;
1148
- for (var it: u32 = 0u; it < 4u; it = it + 1u) {
1149
- let nv = h3(dot(c0v, axis), dot(c1v, axis), dot(c2v, axis));
1150
- let m = max(max(abs(nv.x), abs(nv.y)), abs(nv.z));
1151
- if (m < h(1e-4)) { axis_ok = false; break; }
1152
- axis = nv / m;
1153
- }
1154
- if (axis_ok) {
1155
- axis = axis / length(axis);
1156
- var t_min = h(4.0);
1157
- var t_max = h(-4.0);
1158
- for (var k: u32 = 0u; k < 16u; k = k + 1u) {
1159
- let t = dot(pix[k] - mean, axis);
1160
- t_min = min(t_min, t);
1161
- t_max = max(t_max, t);
1162
- }
1163
- // Inset along the axis by ~half a 565 cell (stb_dxt heuristic, matching
1164
- // the degenerate-case bbox inset below).
1165
- let pad = (t_max - t_min) * h(1.0 / 16.0);
1166
- seed_hi = clamp(mean + (t_max - pad) * axis, h3(0.0), h3(1.0));
1167
- seed_lo = clamp(mean + (t_min + pad) * axis, h3(0.0), h3(1.0));
1168
- } else {
1169
- // Degenerate (near-flat) block: inset bbox seed, as before.
1170
- let inset = (mxv - mn) * h(1.0 / 16.0);
1171
- seed_hi = clamp(mxv - inset, h3(0.0), h3(1.0));
1172
- seed_lo = clamp(mn + inset, h3(0.0), h3(1.0));
1173
- }
1174
- let seed = order565(to565(seed_hi), to565(seed_lo));
1175
- var c0 = seed.x;
1176
- var c1 = seed.y;
1177
-
1178
- // Fused seed pass, then up to TWO least-squares refit rounds (mirroring
1179
- // the high path's iterated refits, at projection cost), each accepted only
1180
- // if the block's squared error actually decreases \u2014 the refit minimises a
1181
- // continuous objective and can lose after 565 quantisation. Every pass
1182
- // re-accumulates the normal-equation sums, so an accepted round seeds the
1183
- // next.
1184
- var cur = project_stats(&pix, c0, c1);
1185
- for (var it: u32 = 0u; it < 2u; it = it + 1u) {
1186
- // Refit only on a well-conditioned system. When every pixel lands on ONE
1187
- // level (flat / near-flat blocks \u2014 note the 4-colour-mode nudge forces
1188
- // c0 \u2260 c1 even for perfectly flat blocks) the system is rank-1: det is 0
1189
- // in exact math and the f16-accumulated det/numerators are pure rounding
1190
- // noise, so the solve returns garbage endpoints. With \u22652 distinct levels
1191
- // 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
1192
- // noise floor \u2014 0.5 separates the two regimes cleanly.
1193
- if (cur.s_min >= cur.s_max) { break; }
1194
- let det = cur.sAA * cur.sBB - cur.sAB * cur.sAB;
1195
- if (abs(det) <= h(0.5)) { break; }
1196
- // Clamp the refit to the block bbox (not [0,1]) \u2014 except for exactly
1197
- // gray blocks, see the load pass: on multi-cluster blocks the
1198
- // unconstrained solve extrapolates far outside the block's colours
1199
- // and the per-channel clamp then bends the hue \u2014 fringe pixels decode to
1200
- // colours that exist nowhere in the block. Constraining to the bbox also
1201
- // measures better in plain SSE (+1.6 dB on the colour test card), so the
1202
- // accept-if-better guard below keeps more refits.
1203
- let e0 = clamp((cur.sBB * cur.sAV - cur.sAB * cur.sBV) / det, lim_lo, lim_hi);
1204
- let e1 = clamp((cur.sAA * cur.sBV - cur.sAB * cur.sAV) / det, lim_lo, lim_hi);
1205
- let rq = order565(to565(e0), to565(e1));
1206
- if (rq.x == c0 && rq.y == c1) { break; }
1207
- let nxt = project_stats(&pix, rq.x, rq.y);
1208
- if (nxt.err >= cur.err) { break; }
1209
- c0 = rq.x;
1210
- c1 = rq.y;
1211
- cur = nxt;
1212
- }
1213
-
1214
- let o = bi * 2u;
1215
- dst[o] = c0 | (c1 << 16u);
1216
- dst[o + 1u] = cur.indices;
1217
- }
1218
- `;
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";
1219
1066
 
1220
1067
  // src/BC1Encoder.ts
1221
1068
  var BC1Encoder = class extends Encoder {
@@ -1242,7 +1089,7 @@ var BC1Encoder = class extends Encoder {
1242
1089
  };
1243
1090
 
1244
1091
  // src/bc5.wgsl
1245
- 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";
1246
1093
 
1247
1094
  // src/bc5_fast_f16.wgsl
1248
1095
  var bc5_fast_f16_default = `// bc5 "fast" encoder \u2014 f16 variant (requires the shader-f16 feature).
@@ -1318,7 +1165,7 @@ var bc5_fast_f16_default = `// bc5 "fast" encoder \u2014 f16 variant (requires t
1318
1165
  enable f16;
1319
1166
  alias h = f16;
1320
1167
  alias h2 = vec2<f16>;
1321
- 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, };
1322
1169
  @group(0) @binding(0) var src_tex: texture_2d<f32>;
1323
1170
  @group(0) @binding(1) var<storage, read_write> dst: array<u32>;
1324
1171
  @group(0) @binding(2) var<uniform> params: Params;
@@ -1360,7 +1207,9 @@ fn refine(sAA: f32, sBB: f32, sAB: f32, sAR: f32, sBR: f32, b0: u32, b1: u32, sp
1360
1207
  }
1361
1208
 
1362
1209
  @compute @workgroup_size(8, 8, 1)
1363
- 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);
1364
1213
  if (gid.x >= params.blocks_x || gid.y >= params.blocks_y) { return; }
1365
1214
  let bi = gid.y * params.blocks_x + gid.x;
1366
1215
  let base = vec2<i32>(i32(gid.x) * 4, i32(gid.y) * 4);
@@ -1504,10 +1353,10 @@ var BC5Encoder = class extends Encoder {
1504
1353
  };
1505
1354
 
1506
1355
  // src/bc7.wgsl
1507
- 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";
1508
1357
 
1509
1358
  // src/bc7_fast_f16.wgsl
1510
- 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";
1511
1360
 
1512
1361
  // src/BC7Encoder.ts
1513
1362
  var BC7Encoder = class extends Encoder {
@@ -1544,80 +1393,93 @@ var BC7Encoder = class extends Encoder {
1544
1393
  };
1545
1394
 
1546
1395
  // src/astc4x4.wgsl
1547
- 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";
1548
1397
 
1549
1398
  // src/astc4x4_fast_f16.wgsl
1550
- var astc4x4_fast_f16_default = `// astc4x4 "fast" encoder \u2014 f16 variant (requires the shader-f16 feature).
1551
- // Same algorithm family as the f32 fallback in astc4x4.wgsl (per-block
1552
- // class selection, principal-axis seed \u2192 projection weight assignment with
1553
- // 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:
1554
1402
  //
1555
1403
  // \u2022 THREE block classes, picked per block from the loaded pixels (the
1556
1404
  // ASTC bit budget trades endpoint bits against weight bits, so a block
1557
1405
  // only pays for the channels it uses):
1558
1406
  // gray + opaque \u2192 CEM 0 (luminance), 5-bit weights, mode 0x253
1559
- // opaque \u2192 CEM 8 (RGB), 3-bit weights, mode 0x053
1560
- // 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
1561
1413
  // "gray" = every texel R == G == B exactly (f16 equality is exact for
1562
- // 8-bit sources), "opaque" = every texel A == 1. The gray path is
1563
- // scalar \u2014 no covariance, no power iteration, no 4-D fit \u2014 and runs
1564
- // [0,255]-integer f16 math like the BC5 kernel (exact endpoints, and
1565
- // 64/span \u2264 64 never overflows f16, unlike a [0,1]-domain 1/dd).
1566
- // \u2022 All projection / refit math in f16 ([0,1] domain, colour paths).
1567
- // The projection direction is pre-scaled by 32 \u2014 a shallow block
1568
- // (endpoints ~1/255 apart) has dd \u2248 1.5e-5, where 3/dd \u2248 2e5 overflows
1569
- // f16 (max 65504) to +inf and the projection dots go subnormal,
1570
- // turning weights and the LSQ refit to garbage (banding on smooth
1571
- // gradients). Scaling dir by 32 puts every intermediate in f16's
1572
- // normal range; s = dot\xB7(32\xB7L/dd\u2083\u2082) is the same quantity (worst case
1573
- // for L = 7 levels: inv = 224/0.0157 \u2248 1.4e4).
1574
- // \u2022 Opaque blocks ship the quantised PCA extents directly (no LSQ fit
1575
- // pass, 8 power iterations \u2014 see the endpoint-selection comment); only
1576
- // the translucent CEM 12 path still refits, and its seed pass only
1577
- // accumulates the LSQ sums \u2014 final weights always come from a
1578
- // 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).
1579
1451
  // \u2022 Endpoint ordering (the blue-contraction rule: sum(e0.rgb) must not
1580
- // exceed sum(e1.rgb)) is applied BEFORE the final projection, so no
1581
- // 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.
1582
1454
  // \u2022 Weight streams are accumulated LSB-first into u32 words and placed
1583
1455
  // into the block's reversed-bit-order field with reverseBits() \u2014
1584
- // stream bit q lives at block bit 127 \u2212 q, so a whole stream word
1585
- // maps onto a block word with a single bit reversal.
1586
- // \u2022 The covariance moments stay in their OWN pass, deliberately: fusing
1587
- // them into the load loop bc7-style (pixel-0 residuals, with or without
1588
- // hoisting pixel 0 out of the loop) measured +5% GPU time on 4096\xB2
1589
- // (/ab A/B, 2026-07, M3) \u2014 the separate loop overlaps the 16 texture
1590
- // loads' latency better than a longer in-loop dependency chain does.
1591
- // Per-pass cost on the same rig, for future tuning: covariance+power-
1592
- // iteration \u2248 18%, LSQ fit pass \u2248 24%, extents pass \u2248 6% of the kernel;
1593
- // @workgroup_size 16\xD78 measured exactly at par with 8\xD78.
1456
+ // stream bit q lives at block bit 127 \u2212 q.
1594
1457
  //
1595
- // RESTRICTED SUBSET + BLOCK LAYOUT: see astc4x4_ref.ts (single partition,
1596
- // CEM 0/8/12, 8-bit endpoints, plain-bit weight ISE, block modes
1597
- // 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).
1598
1461
  //
1599
1462
  // The host selects this module only when the device reports shader-f16,
1600
1463
  // falling back to astc4x4.wgsl otherwise.
1601
1464
  enable f16;
1602
1465
  alias h = f16;
1466
+ alias h3 = vec3<f16>;
1603
1467
  alias h4 = vec4<f16>;
1604
- 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, };
1605
1469
  @group(0) @binding(0) var src_tex: texture_2d<f32>;
1606
1470
  @group(0) @binding(1) var<storage, read_write> dst: array<u32>;
1607
1471
  @group(0) @binding(2) var<uniform> params: Params;
1608
1472
 
1609
1473
  // Fused projection + least-squares refit against the 4-level QUANT_4
1610
- // palette \u2014 only the translucent (CEM 12) path still refits: the 2-bit
1611
- // weight grid is coarse enough to need it, while the opaque paths get
1612
- // 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.
1613
1476
  struct Fit { e0: h4, e1: h4, valid: bool, wstream: u32 };
1614
1477
  fn proj_fit(pix: ptr<function, array<h4, 16>>, e0: h4, e1: h4) -> Fit {
1615
1478
  var out: Fit;
1616
1479
  out.valid = false;
1617
1480
  out.wstream = 0u;
1618
- // dir pre-scaled by 32 to keep dd and the projection dots in f16's normal
1619
- // range (see header). Spans below ~0.7 of an 8-bit step (dd\u2083\u2082 < 0.008,
1620
- // 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.
1621
1483
  let dir = (e1 - e0) * h(32.0);
1622
1484
  let dd = dot(dir, dir);
1623
1485
  if (dd < h(0.008)) { return out; }
@@ -1625,7 +1487,7 @@ fn proj_fit(pix: ptr<function, array<h4, 16>>, e0: h4, e1: h4) -> Fit {
1625
1487
  var sAA = h(0.0); var sBB = h(0.0); var sAB = h(0.0);
1626
1488
  var sAV = h4(0.0); var sBV = h4(0.0);
1627
1489
  var s_min = h(3.0); var s_max = h(0.0);
1628
- // 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
1629
1491
  // commutes with the shift): accumulators scale with the block span, keeping
1630
1492
  // f16 rounding a fraction of the span instead of \xB11 level at high absolute
1631
1493
  // values.
@@ -1640,9 +1502,9 @@ fn proj_fit(pix: ptr<function, array<h4, 16>>, e0: h4, e1: h4) -> Fit {
1640
1502
  sAV = sAV + a * vr; sBV = sBV + b * vr;
1641
1503
  }
1642
1504
  // Rank-1 guard: if every pixel projects to ONE level the system is
1643
- // singular \u2014 det/numerators are pure f16 rounding noise and the solve
1644
- // returns garbage endpoints. With \u22652 distinct levels
1645
- // 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.
1646
1508
  if (s_min == s_max) { return out; }
1647
1509
  let det = sAA * sBB - sAB * sAB;
1648
1510
  if (abs(det) < h(0.5)) { return out; }
@@ -1652,12 +1514,39 @@ fn proj_fit(pix: ptr<function, array<h4, 16>>, e0: h4, e1: h4) -> Fit {
1652
1514
  return out;
1653
1515
  }
1654
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
+
1655
1542
  fn q8(e: h4) -> vec4<u32> {
1656
1543
  return vec4<u32>(clamp(floor(e * h(255.0) + h(0.5)), h4(0.0), h4(255.0)));
1657
1544
  }
1658
1545
 
1659
1546
  @compute @workgroup_size(8, 8, 1)
1660
- 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);
1661
1550
  if (gid.x >= params.blocks_x || gid.y >= params.blocks_y) { return; }
1662
1551
  let bi = gid.y * params.blocks_x + gid.x;
1663
1552
  let base = vec2<i32>(i32(gid.x) * 4, i32(gid.y) * 4);
@@ -1720,20 +1609,136 @@ fn encode(@builtin(global_invocation_id) gid: vec3<u32>) {
1720
1609
  w1 = (L1 >> 7u) | reverseBits(s2);
1721
1610
  w2 = reverseBits(s1);
1722
1611
  w3 = reverseBits(s0);
1723
- } else {
1724
- // ------------- 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
+ //
1725
1618
  // Seed endpoints from the block's principal colour axis (covariance
1726
1619
  // power-iteration, seeded with the bbox diagonal). The bbox diagonal is
1727
1620
  // sign-blind: on anti-correlated channels (normal maps, hue edges) it
1728
- // points across the data instead of along it, and the LSQ refit \u2014 which
1729
- // fits endpoints GIVEN the projection weights \u2014 can't recover from a
1730
- // wrong axis. Deviations are pre-scaled \xD716 so covariance entries for
1731
- // shallow blocks stay in f16's normal range (span ~1/255 \u2192 d\xB2 \u2248 1e-3)
1732
- // while full-range sums stay \u22644096; the iteration renormalises by the
1733
- // max component (a plain length() of the matvec output could overflow
1734
- // f16), so only the direction survives.
1735
- var seed_lo = lo;
1736
- 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.
1737
1742
  var c0v = h4(0.0);
1738
1743
  var c1v = h4(0.0);
1739
1744
  var c2v = h4(0.0);
@@ -1745,29 +1750,18 @@ fn encode(@builtin(global_invocation_id) gid: vec3<u32>) {
1745
1750
  c2v = c2v + d.z * d;
1746
1751
  c3v = c3v + d.w * d;
1747
1752
  }
1753
+ var seed_lo = lo;
1754
+ var seed_hi = hi;
1748
1755
  var axis = hi - lo;
1749
1756
  var axis_ok = true;
1750
- // 4 shared iterations, then 4 more for opaque blocks only \u2014 two
1751
- // FIXED-bound loops rather than one divergent trip count, so both
1752
- // unroll. Opaque blocks need the converged axis (it IS the endpoint
1753
- // quality there; 4 was under-converged on noisy 4-D content), while
1754
- // translucent blocks' LSQ refit absorbs residual axis error \u2014 their
1755
- // extra 4 steps measured exactly 0.000 dB on the alpha card for
1756
- // ~5% GPU.
1757
+ // 4 iterations: the refit absorbs residual axis error (4 more measured
1758
+ // 0.000 dB on the alpha card).
1757
1759
  for (var it: u32 = 0u; it < 4u; it = it + 1u) {
1758
1760
  let nv = h4(dot(c0v, axis), dot(c1v, axis), dot(c2v, axis), dot(c3v, axis));
1759
1761
  let m = max(max(abs(nv.x), abs(nv.y)), max(abs(nv.z), abs(nv.w)));
1760
1762
  if (m < h(1e-4)) { axis_ok = false; break; }
1761
1763
  axis = nv / m;
1762
1764
  }
1763
- if (axis_ok && opaque) {
1764
- for (var it: u32 = 0u; it < 4u; it = it + 1u) {
1765
- let nv = h4(dot(c0v, axis), dot(c1v, axis), dot(c2v, axis), dot(c3v, axis));
1766
- let m = max(max(abs(nv.x), abs(nv.y)), max(abs(nv.z), abs(nv.w)));
1767
- if (m < h(1e-4)) { axis_ok = false; break; }
1768
- axis = nv / m;
1769
- }
1770
- }
1771
1765
  if (axis_ok) {
1772
1766
  axis = axis / length(axis);
1773
1767
  var t_min = h(4.0);
@@ -1780,100 +1774,52 @@ fn encode(@builtin(global_invocation_id) gid: vec3<u32>) {
1780
1774
  seed_lo = clamp(mean + t_min * axis, h4(0.0), h4(1.0));
1781
1775
  seed_hi = clamp(mean + t_max * axis, h4(0.0), h4(1.0));
1782
1776
  }
1783
-
1784
- // Endpoint selection. OPAQUE blocks (CEM 8, 8-level weights) quantise
1785
- // the PCA-extent seed directly, BC7-style \u2014 the LSQ fit pass measured
1786
- // +26% GPU for \u22120.06..+0.31 dB against the converged 8-step axis
1787
- // (/ab + PSNR A/B, 2026-07): at 8 weight levels the projection is fine
1788
- // enough that a good axis, not a refit, carries the quality.
1789
- // TRANSLUCENT blocks (CEM 12) keep the fit: 4 levels are coarse enough
1790
- // that dropping it costs 0.5+ dB. Its result is clamped to the block
1791
- // bbox: on multi-cluster blocks the unconstrained solve extrapolates
1792
- // far outside the block's colours and the per-channel [0,1] clamp then
1793
- // bends the hue \u2014 fringe pixels decode to colours that exist nowhere
1794
- // in the block (and the bbox constraint also measures better in plain
1795
- // 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.
1796
1780
  var e0 = lo;
1797
1781
  var e1 = hi;
1798
1782
  var fitStream = 0u;
1799
1783
  var haveFitWeights = false;
1800
- if (opaque) {
1801
- // Bbox-clamped like the fit output: on multi-cluster blocks the axis
1802
- // extents overshoot the data per-channel and decode to colours that
1803
- // exist nowhere in the block (the odd-size padding gate caught a
1804
- // \u22123.9 dB crop without this).
1805
- e0 = clamp(seed_lo, lo, hi);
1806
- e1 = clamp(seed_hi, lo, hi);
1807
- } else {
1808
- let r = proj_fit(&pix, seed_lo, seed_hi);
1809
- if (r.valid) {
1810
- e0 = clamp(r.e0, lo, hi);
1811
- e1 = clamp(r.e1, lo, hi);
1812
- fitStream = r.wstream;
1813
- haveFitWeights = true;
1814
- }
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;
1815
1790
  }
1816
1791
  var E0 = q8(e0);
1817
1792
  var E1 = q8(e1);
1818
-
1819
- // Blue-contraction ordering, applied before the weight pass so weights
1820
- // are already oriented (no reflection needed).
1821
1793
  var swapped = false;
1822
1794
  if (E0.x + E0.y + E0.z > E1.x + E1.y + E1.z) {
1823
1795
  let t = E0; E0 = E1; E1 = t;
1824
1796
  swapped = true;
1825
1797
  }
1826
- let d0 = h4(vec4<f32>(E0)) * h(1.0 / 255.0);
1827
- let d1 = h4(vec4<f32>(E1)) * h(1.0 / 255.0);
1828
-
1829
- // Weight pass against the decoded endpoints. Same \xD732 pre-scale as
1830
- // proj_fit; distinct 8-bit endpoints are \u22651/255 apart (dd\u2083\u2082 \u2265 0.0157),
1831
- // so the flat threshold only catches identical ones.
1832
- let dir = (d1 - d0) * h(32.0);
1833
- let dd = dot(dir, dir);
1834
- if (opaque) {
1835
- // CEM 8: 3-bit weights, stream bit q = 3k.
1836
- var s0 = 0u; var s1 = 0u;
1837
- if (dd >= h(0.008)) {
1838
- let inv = h(224.0) / dd;
1839
- for (var k: u32 = 0u; k < 16u; k = k + 1u) {
1840
- let w = u32(clamp(floor(dot(pix[k] - d0, dir) * inv + h(0.5)), h(0.0), h(7.0)));
1841
- let off = 3u * k;
1842
- if (off < 30u) { s0 = s0 | (w << off); }
1843
- else if (off == 30u) { s0 = s0 | (w << 30u); s1 = s1 | (w >> 2u); }
1844
- else { s1 = s1 | (w << (off - 32u)); }
1845
- }
1846
- }
1847
- // Mode 0x053, CEM 8 @13, endpoints R0 R1 G0 G1 B0 B1 from bit 17.
1848
- w0 = 0x053u | (8u << 13u) | (E0.x << 17u) | (E1.x << 25u);
1849
- w1 = (E1.x >> 7u) | (E0.y << 1u) | (E1.y << 9u) | (E0.z << 17u) | (E1.z << 25u);
1850
- w2 = (E1.z >> 7u) | reverseBits(s1);
1851
- 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);
1852
1805
  } else {
1853
- // CEM 12: 2-bit weights, stream bit q = 2k (single stream word).
1854
- // Valid fits ship the FIT-PASS weights instead of reprojecting \u2014
1855
- // worth a whole 16-pixel pass for \u22120.09 dB on the alpha card (/ab +
1856
- // PSNR A/B, 2026-07; the pre-adaptive-CEM encoder rejected this same
1857
- // trade when EVERY block was CEM 12 \u2014 now only translucent blocks
1858
- // pay it). The blue-contraction swap is a full reflection w \u2192 3\u2212w,
1859
- // i.e. bitwise NOT of the packed stream. Invalid fits (rank-1 /
1860
- // degenerate) fall back to reprojection against the bbox endpoints.
1861
- var s0 = 0u;
1862
- if (haveFitWeights) {
1863
- s0 = select(fitStream, ~fitStream, swapped);
1864
- } 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)) {
1865
1811
  let inv = h(96.0) / dd;
1866
1812
  for (var k: u32 = 0u; k < 16u; k = k + 1u) {
1867
1813
  let w = u32(clamp(floor(dot(pix[k] - d0, dir) * inv + h(0.5)), h(0.0), h(3.0)));
1868
1814
  s0 = s0 | (w << (2u * k));
1869
1815
  }
1870
1816
  }
1871
- // Mode 0x042, CEM 12 @13, endpoints R0 R1 G0 G1 B0 B1 A0 A1 from 17.
1872
- w0 = 0x042u | (12u << 13u) | (E0.x << 17u) | (E1.x << 25u);
1873
- w1 = (E1.x >> 7u) | (E0.y << 1u) | (E1.y << 9u) | (E0.z << 17u) | (E1.z << 25u);
1874
- w2 = (E1.z >> 7u) | (E0.w << 1u) | (E1.w << 9u);
1875
- w3 = reverseBits(s0);
1876
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);
1877
1823
  }
1878
1824
 
1879
1825
  let o = bi * 4u;
@@ -1906,10 +1852,10 @@ var ASTC4x4Encoder = class extends Encoder {
1906
1852
  };
1907
1853
 
1908
1854
  // src/etc2.wgsl
1909
- 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";
1910
1856
 
1911
1857
  // src/etc2_fast_f16.wgsl
1912
- 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";
1913
1859
 
1914
1860
  // src/ETC2Encoder.ts
1915
1861
  var ETC2Encoder = class extends Encoder {
@@ -2629,87 +2575,7 @@ async function rasterizeSvg(source, options = {}) {
2629
2575
  }
2630
2576
  }
2631
2577
 
2632
- // src/three/compressTexture.ts
2633
- import { ClampToEdgeWrapping as ClampToEdgeWrapping2, LinearFilter as LinearFilter2, LinearSRGBColorSpace as LinearSRGBColorSpace2, SRGBColorSpace as SRGBColorSpace2, Texture } from "three";
2634
-
2635
- // src/three/buildTexture.ts
2636
- import {
2637
- RED_GREEN_RGTC2_Format,
2638
- RGB_ETC2_Format,
2639
- RGBA_ASTC_4x4_Format,
2640
- RGBA_BPTC_Format,
2641
- RGBA_S3TC_DXT1_Format
2642
- } from "three";
2643
-
2644
- // src/three/textureAssembly.ts
2645
- import {
2646
- ClampToEdgeWrapping,
2647
- CompressedTexture,
2648
- LinearFilter,
2649
- LinearMipmapLinearFilter,
2650
- LinearSRGBColorSpace,
2651
- SRGBColorSpace
2652
- } from "three";
2653
- function assembleCompressedTexture(levels, threeFormat, effectiveSrgb) {
2654
- if (levels.length === 0) {
2655
- throw new Error("assembleCompressedTexture: no levels provided");
2656
- }
2657
- const mipmaps = levels.map((l) => ({
2658
- data: l.data,
2659
- width: l.paddedWidth,
2660
- height: l.paddedHeight
2661
- }));
2662
- const base = levels[0];
2663
- const texture = new CompressedTexture(mipmaps, base.paddedWidth, base.paddedHeight, threeFormat);
2664
- texture.colorSpace = effectiveSrgb ? SRGBColorSpace : LinearSRGBColorSpace;
2665
- texture.magFilter = LinearFilter;
2666
- texture.minFilter = levels.length > 1 ? LinearMipmapLinearFilter : LinearFilter;
2667
- texture.generateMipmaps = false;
2668
- texture.wrapS = texture.wrapT = ClampToEdgeWrapping;
2669
- texture.needsUpdate = true;
2670
- texture.userData.logicalWidth = base.width;
2671
- texture.userData.logicalHeight = base.height;
2672
- texture.userData.mipLevels = levels.length;
2673
- return texture;
2674
- }
2675
-
2676
- // src/three/buildTexture.ts
2677
- var THREE_FORMAT = {
2678
- [TextureFormat.BC1]: RGBA_S3TC_DXT1_Format,
2679
- [TextureFormat.BC1_SRGB]: RGBA_S3TC_DXT1_Format,
2680
- [TextureFormat.BC5]: RED_GREEN_RGTC2_Format,
2681
- [TextureFormat.BC7]: RGBA_BPTC_Format,
2682
- [TextureFormat.BC7_SRGB]: RGBA_BPTC_Format,
2683
- [TextureFormat.ASTC_4x4]: RGBA_ASTC_4x4_Format,
2684
- [TextureFormat.ASTC_4x4_SRGB]: RGBA_ASTC_4x4_Format,
2685
- [TextureFormat.ETC2_RGB8]: RGB_ETC2_Format,
2686
- [TextureFormat.ETC2_RGB8_SRGB]: RGB_ETC2_Format
2687
- };
2688
- var SRGB_FORMATS = /* @__PURE__ */ new Set([
2689
- TextureFormat.BC1_SRGB,
2690
- TextureFormat.BC7_SRGB,
2691
- TextureFormat.ASTC_4x4_SRGB,
2692
- TextureFormat.ETC2_RGB8_SRGB
2693
- ]);
2694
- function isSrgbFormat(format) {
2695
- return SRGB_FORMATS.has(format);
2696
- }
2697
- function threeFormatFor(format) {
2698
- return THREE_FORMAT[format];
2699
- }
2700
- function buildCompressedTexture(levels, format) {
2701
- return assembleCompressedTexture(levels, threeFormatFor(format), isSrgbFormat(format));
2702
- }
2703
- async function encodeToTexture(encoder, source, { colorSpace = "srgb", flipY = false } = {}) {
2704
- const formats = encoder.constructor.textureFormats;
2705
- const wantSrgb = colorSpace === "srgb" && encoder.supportsSrgb;
2706
- const format = formats.find((f) => isSrgbFormat(f) === wantSrgb) ?? formats[0];
2707
- const bytes = await encoder.encodeToBytes(source, { flipY });
2708
- const texture = buildCompressedTexture([bytes], format);
2709
- return { ...bytes, texture };
2710
- }
2711
-
2712
- // src/three/transcodeCache.ts
2578
+ // src/transcodeCache.ts
2713
2579
  var DEFAULT_LIMIT = 256 * 1024 * 1024;
2714
2580
  var maxBytes = DEFAULT_LIMIT;
2715
2581
  var totalBytes = 0;
@@ -2782,7 +2648,7 @@ function clearTranscodeCache() {
2782
2648
  totalBytes = 0;
2783
2649
  }
2784
2650
 
2785
- // src/three/compressTexture.ts
2651
+ // src/compressTexture.ts
2786
2652
  var sharedGpuPromise = null;
2787
2653
  var SHARED_DEVICE_FEATURES = [
2788
2654
  "texture-compression-bc",
@@ -2915,18 +2781,7 @@ function bitmapToMipLevel(bitmap, flipY) {
2915
2781
  function mipLevelToImageData(level) {
2916
2782
  return new ImageData(level.data, level.width, level.height);
2917
2783
  }
2918
- function wrapUncompressed(bitmap, srgb, flipY) {
2919
- const tex = new Texture(bitmap);
2920
- tex.colorSpace = srgb ? SRGBColorSpace2 : LinearSRGBColorSpace2;
2921
- tex.magFilter = LinearFilter2;
2922
- tex.minFilter = LinearFilter2;
2923
- tex.wrapS = tex.wrapT = ClampToEdgeWrapping2;
2924
- tex.generateMipmaps = false;
2925
- tex.flipY = flipY;
2926
- tex.needsUpdate = true;
2927
- return tex;
2928
- }
2929
- async function compressTexture(source, options = {}) {
2784
+ async function compressTextureToBytes(source, options = {}) {
2930
2785
  const {
2931
2786
  hint = "color",
2932
2787
  preferredFormat,
@@ -2940,7 +2795,6 @@ async function compressTexture(source, options = {}) {
2940
2795
  device: providedDevice,
2941
2796
  adapter: providedAdapter
2942
2797
  } = options;
2943
- const srgb = colorSpace === "srgb";
2944
2798
  const t0 = performance.now();
2945
2799
  const gpu = await resolveWebGPU();
2946
2800
  const gl = gpu ? null : resolveWebGL();
@@ -2957,9 +2811,9 @@ async function compressTexture(source, options = {}) {
2957
2811
  if (transcodeKey) {
2958
2812
  const hit = readTranscodeCache(transcodeKey);
2959
2813
  if (hit) {
2960
- const tex2 = buildCompressedTexture(hit.levels, hit.format);
2961
2814
  return {
2962
- texture: tex2,
2815
+ levels: hit.levels,
2816
+ fallbackBitmap: null,
2963
2817
  format: hit.format,
2964
2818
  fallbackUncompressed: false,
2965
2819
  backend: gpu ? "webgpu" : "webgl",
@@ -2970,10 +2824,7 @@ async function compressTexture(source, options = {}) {
2970
2824
  encodeMs: 0,
2971
2825
  decodeMs: 0,
2972
2826
  totalMs: performance.now() - t0,
2973
- cacheHit: true,
2974
- destroy: () => {
2975
- tex2.dispose();
2976
- }
2827
+ cacheHit: true
2977
2828
  };
2978
2829
  }
2979
2830
  }
@@ -2985,11 +2836,11 @@ async function compressTexture(source, options = {}) {
2985
2836
  const viaWebGL = gl ? encodeViaWebGL(gl) : null;
2986
2837
  if (viaWebGL) return viaWebGL;
2987
2838
  console.warn(
2988
- "[compressTexture] No compressed path available (WebGPU and WebGL2 both lack a usable compressed-texture format); returning uncompressed RGBA8."
2839
+ "[compressTextureToBytes] No compressed path available (WebGPU and WebGL2 both lack a usable compressed-texture format); returning uncompressed RGBA8."
2989
2840
  );
2990
- const tex = wrapUncompressed(bitmap, srgb, flipY);
2991
2841
  return {
2992
- texture: tex,
2842
+ levels: null,
2843
+ fallbackBitmap: bitmap,
2993
2844
  format: null,
2994
2845
  fallbackUncompressed: true,
2995
2846
  backend: "none",
@@ -3000,10 +2851,7 @@ async function compressTexture(source, options = {}) {
3000
2851
  encodeMs: 0,
3001
2852
  decodeMs,
3002
2853
  totalMs: performance.now() - t0,
3003
- cacheHit: false,
3004
- destroy: () => {
3005
- tex.dispose();
3006
- }
2854
+ cacheHit: false
3007
2855
  };
3008
2856
  async function resolveWebGPU() {
3009
2857
  if (!("gpu" in navigator)) return null;
@@ -3056,7 +2904,6 @@ async function compressTexture(source, options = {}) {
3056
2904
  } else {
3057
2905
  bytes = await encoder.encodeToBytes(bitmap, { flipY });
3058
2906
  }
3059
- const tex3 = buildCompressedTexture([bytes], selection.format);
3060
2907
  if (transcodeKey) {
3061
2908
  writeTranscodeCache(transcodeKey, {
3062
2909
  format: selection.format,
@@ -3065,8 +2912,10 @@ async function compressTexture(source, options = {}) {
3065
2912
  levels: [bytes]
3066
2913
  });
3067
2914
  }
2915
+ destroyEncoder();
3068
2916
  return {
3069
- texture: tex3,
2917
+ levels: [bytes],
2918
+ fallbackBitmap: null,
3070
2919
  format: selection.format,
3071
2920
  fallbackUncompressed: false,
3072
2921
  backend: "webgpu",
@@ -3077,11 +2926,7 @@ async function compressTexture(source, options = {}) {
3077
2926
  encodeMs: bytes.encodeMs,
3078
2927
  decodeMs,
3079
2928
  totalMs: performance.now() - t0,
3080
- cacheHit: false,
3081
- destroy: () => {
3082
- tex3.dispose();
3083
- destroyEncoder();
3084
- }
2929
+ cacheHit: false
3085
2930
  };
3086
2931
  }
3087
2932
  let chainResult;
@@ -3097,7 +2942,6 @@ async function compressTexture(source, options = {}) {
3097
2942
  }
3098
2943
  }
3099
2944
  const { levels, encodeMs } = chainResult;
3100
- const tex2 = buildCompressedTexture(levels, selection.format);
3101
2945
  if (transcodeKey) {
3102
2946
  writeTranscodeCache(transcodeKey, {
3103
2947
  format: selection.format,
@@ -3106,8 +2950,10 @@ async function compressTexture(source, options = {}) {
3106
2950
  levels
3107
2951
  });
3108
2952
  }
2953
+ destroyEncoder();
3109
2954
  return {
3110
- texture: tex2,
2955
+ levels,
2956
+ fallbackBitmap: null,
3111
2957
  format: selection.format,
3112
2958
  fallbackUncompressed: false,
3113
2959
  backend: "webgpu",
@@ -3118,11 +2964,7 @@ async function compressTexture(source, options = {}) {
3118
2964
  encodeMs,
3119
2965
  decodeMs,
3120
2966
  totalMs: performance.now() - t0,
3121
- cacheHit: false,
3122
- destroy: () => {
3123
- tex2.dispose();
3124
- destroyEncoder();
3125
- }
2967
+ cacheHit: false
3126
2968
  };
3127
2969
  } catch (e) {
3128
2970
  destroyEncoder();
@@ -3142,7 +2984,6 @@ async function compressTexture(source, options = {}) {
3142
2984
  try {
3143
2985
  if (!mipmaps) {
3144
2986
  const bytes = encoder.encodeToBytes(bitmap, { flipY });
3145
- const tex3 = buildCompressedTexture([bytes], selection.format);
3146
2987
  if (transcodeKey) {
3147
2988
  writeTranscodeCache(transcodeKey, {
3148
2989
  format: selection.format,
@@ -3151,8 +2992,10 @@ async function compressTexture(source, options = {}) {
3151
2992
  levels: [bytes]
3152
2993
  });
3153
2994
  }
2995
+ encoder.destroy();
3154
2996
  return {
3155
- texture: tex3,
2997
+ levels: [bytes],
2998
+ fallbackBitmap: null,
3156
2999
  format: selection.format,
3157
3000
  fallbackUncompressed: false,
3158
3001
  backend: "webgl",
@@ -3163,11 +3006,7 @@ async function compressTexture(source, options = {}) {
3163
3006
  encodeMs: bytes.encodeMs,
3164
3007
  decodeMs,
3165
3008
  totalMs: performance.now() - t0,
3166
- cacheHit: false,
3167
- destroy: () => {
3168
- tex3.dispose();
3169
- encoder.destroy();
3170
- }
3009
+ cacheHit: false
3171
3010
  };
3172
3011
  }
3173
3012
  const level0 = bitmapToMipLevel(bitmap, flipY);
@@ -3180,7 +3019,6 @@ async function compressTexture(source, options = {}) {
3180
3019
  encodedLevels.push(bytes);
3181
3020
  totalEncodeMs += bytes.encodeMs;
3182
3021
  }
3183
- const tex2 = buildCompressedTexture(encodedLevels, selection.format);
3184
3022
  if (transcodeKey) {
3185
3023
  writeTranscodeCache(transcodeKey, {
3186
3024
  format: selection.format,
@@ -3189,8 +3027,10 @@ async function compressTexture(source, options = {}) {
3189
3027
  levels: encodedLevels
3190
3028
  });
3191
3029
  }
3030
+ encoder.destroy();
3192
3031
  return {
3193
- texture: tex2,
3032
+ levels: encodedLevels,
3033
+ fallbackBitmap: null,
3194
3034
  format: selection.format,
3195
3035
  fallbackUncompressed: false,
3196
3036
  backend: "webgl",
@@ -3201,20 +3041,114 @@ async function compressTexture(source, options = {}) {
3201
3041
  encodeMs: totalEncodeMs,
3202
3042
  decodeMs,
3203
3043
  totalMs: performance.now() - t0,
3204
- cacheHit: false,
3205
- destroy: () => {
3206
- tex2.dispose();
3207
- encoder.destroy();
3208
- }
3044
+ cacheHit: false
3209
3045
  };
3210
3046
  } catch (e) {
3211
3047
  encoder.destroy();
3212
- console.warn("[compressTexture] WebGL fallback encode failed; returning uncompressed RGBA8.", e);
3048
+ console.warn("[compressTextureToBytes] WebGL fallback encode failed; returning uncompressed RGBA8.", e);
3213
3049
  return null;
3214
3050
  }
3215
3051
  }
3216
3052
  }
3217
3053
 
3054
+ // src/three/compressTexture.ts
3055
+ import { ClampToEdgeWrapping as ClampToEdgeWrapping2, LinearFilter as LinearFilter2, LinearSRGBColorSpace as LinearSRGBColorSpace2, SRGBColorSpace as SRGBColorSpace2, Texture } from "three";
3056
+
3057
+ // src/three/buildTexture.ts
3058
+ import {
3059
+ RED_GREEN_RGTC2_Format,
3060
+ RGB_ETC2_Format,
3061
+ RGBA_ASTC_4x4_Format,
3062
+ RGBA_BPTC_Format,
3063
+ RGBA_S3TC_DXT1_Format
3064
+ } from "three";
3065
+
3066
+ // src/three/textureAssembly.ts
3067
+ import {
3068
+ ClampToEdgeWrapping,
3069
+ CompressedTexture,
3070
+ LinearFilter,
3071
+ LinearMipmapLinearFilter,
3072
+ LinearSRGBColorSpace,
3073
+ SRGBColorSpace
3074
+ } from "three";
3075
+ function assembleCompressedTexture(levels, threeFormat, effectiveSrgb) {
3076
+ if (levels.length === 0) {
3077
+ throw new Error("assembleCompressedTexture: no levels provided");
3078
+ }
3079
+ const mipmaps = levels.map((l) => ({
3080
+ data: l.data,
3081
+ width: l.paddedWidth,
3082
+ height: l.paddedHeight
3083
+ }));
3084
+ const base = levels[0];
3085
+ const texture = new CompressedTexture(mipmaps, base.paddedWidth, base.paddedHeight, threeFormat);
3086
+ texture.colorSpace = effectiveSrgb ? SRGBColorSpace : LinearSRGBColorSpace;
3087
+ texture.magFilter = LinearFilter;
3088
+ texture.minFilter = levels.length > 1 ? LinearMipmapLinearFilter : LinearFilter;
3089
+ texture.generateMipmaps = false;
3090
+ texture.wrapS = texture.wrapT = ClampToEdgeWrapping;
3091
+ texture.needsUpdate = true;
3092
+ texture.userData.logicalWidth = base.width;
3093
+ texture.userData.logicalHeight = base.height;
3094
+ texture.userData.mipLevels = levels.length;
3095
+ return texture;
3096
+ }
3097
+
3098
+ // src/three/buildTexture.ts
3099
+ var THREE_FORMAT = {
3100
+ [TextureFormat.BC1]: RGBA_S3TC_DXT1_Format,
3101
+ [TextureFormat.BC1_SRGB]: RGBA_S3TC_DXT1_Format,
3102
+ [TextureFormat.BC5]: RED_GREEN_RGTC2_Format,
3103
+ [TextureFormat.BC7]: RGBA_BPTC_Format,
3104
+ [TextureFormat.BC7_SRGB]: RGBA_BPTC_Format,
3105
+ [TextureFormat.ASTC_4x4]: RGBA_ASTC_4x4_Format,
3106
+ [TextureFormat.ASTC_4x4_SRGB]: RGBA_ASTC_4x4_Format,
3107
+ [TextureFormat.ETC2_RGB8]: RGB_ETC2_Format,
3108
+ [TextureFormat.ETC2_RGB8_SRGB]: RGB_ETC2_Format
3109
+ };
3110
+ var SRGB_FORMATS = /* @__PURE__ */ new Set([
3111
+ TextureFormat.BC1_SRGB,
3112
+ TextureFormat.BC7_SRGB,
3113
+ TextureFormat.ASTC_4x4_SRGB,
3114
+ TextureFormat.ETC2_RGB8_SRGB
3115
+ ]);
3116
+ function isSrgbFormat(format) {
3117
+ return SRGB_FORMATS.has(format);
3118
+ }
3119
+ function threeFormatFor(format) {
3120
+ return THREE_FORMAT[format];
3121
+ }
3122
+ function buildCompressedTexture(levels, format) {
3123
+ return assembleCompressedTexture(levels, threeFormatFor(format), isSrgbFormat(format));
3124
+ }
3125
+ async function encodeToTexture(encoder, source, { colorSpace = "srgb", flipY = false } = {}) {
3126
+ const formats = encoder.constructor.textureFormats;
3127
+ const wantSrgb = colorSpace === "srgb" && encoder.supportsSrgb;
3128
+ const format = formats.find((f) => isSrgbFormat(f) === wantSrgb) ?? formats[0];
3129
+ const bytes = await encoder.encodeToBytes(source, { flipY });
3130
+ const texture = buildCompressedTexture([bytes], format);
3131
+ return { ...bytes, texture };
3132
+ }
3133
+
3134
+ // src/three/compressTexture.ts
3135
+ function wrapUncompressed(bitmap, srgb, flipY) {
3136
+ const tex = new Texture(bitmap);
3137
+ tex.colorSpace = srgb ? SRGBColorSpace2 : LinearSRGBColorSpace2;
3138
+ tex.magFilter = LinearFilter2;
3139
+ tex.minFilter = LinearFilter2;
3140
+ tex.wrapS = tex.wrapT = ClampToEdgeWrapping2;
3141
+ tex.generateMipmaps = false;
3142
+ tex.flipY = flipY;
3143
+ tex.needsUpdate = true;
3144
+ return tex;
3145
+ }
3146
+ async function compressTexture(source, options = {}) {
3147
+ const { levels, fallbackBitmap, ...rest } = await compressTextureToBytes(source, options);
3148
+ const texture = fallbackBitmap !== null ? wrapUncompressed(fallbackBitmap, options.colorSpace !== "linear", options.flipY ?? true) : buildCompressedTexture(levels, rest.format);
3149
+ return { ...rest, texture, destroy: () => texture.dispose() };
3150
+ }
3151
+
3218
3152
  // src/three/GputexLoader.ts
3219
3153
  import { Loader } from "three";
3220
3154
  var GputexLoader = class extends Loader {
@@ -3327,6 +3261,7 @@ export {
3327
3261
  buildCompressedTexture,
3328
3262
  clearTranscodeCache,
3329
3263
  compressTexture,
3264
+ compressTextureToBytes,
3330
3265
  createWebGLContext,
3331
3266
  detectCapabilities,
3332
3267
  detectWebGLCapabilities,