Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 19 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,14 +94,21 @@ once from levels assigned against a slightly inset range (so the extreme
levels gather every pixel near the extremes, not just the extreme pixel),
then shifts both endpoints by the mean residual of the final levels, and
encodes blocks spanning ≤ 7 values losslessly — roughly 4–9% lower error than a
plain min/max seed on real textures, up to 60% on displacement maps. BC7's
16-level mode-6 palette makes the refit redundant on a principal-axis seed
(≤0.05 dB). ASTC spends every one of its 128 bits: a
wide-span opaque block gets 16 weight levels with 192-level (trit-coded)
endpoints, a small-span one exact 8-bit endpoints with 8 levels,
exactly-grayscale blocks a luminance-only mode with 32 levels. On GPUs that
report the `shader-f16` feature everything runs in f16 — the f32 shaders
are the automatic fallback.
plain min/max seed on real textures, up to 60% on displacement maps. BC7
chooses per block between mode 6 (one RGBA line with a 16-level palette,
fine enough that a principal-axis seed needs no refit) and mode 4, which
splits the block's least line-like channel into its own scalar plane —
normal maps, channel-packed atlases, noisy photo chroma, independent alpha.
The choice comes straight from the block covariance (the variance each mode
explains net of its quantisation) and both modes run the same per-pixel
passes, so it costs no GPU time: −33% error (MSE) across the test corpus,
+2.4–4.5 dB on normal maps, +1.1–2.2 dB on noisy photo colour (smooth wood
colour and gray maps are mode-6 content and unchanged). ASTC spends every
one of its 128 bits: a wide-span opaque block gets 16 weight levels with
192-level (trit-coded) endpoints, a small-span one exact 8-bit endpoints
with 8 levels, exactly-grayscale blocks a luminance-only mode with 32
levels. On GPUs that report the `shader-f16` feature everything runs in
f16 — the f32 shaders are the automatic fallback.

ETC2 is the exception to the endpoint-line story: its blocks are per-subblock
base colours shifted by scalar modifier tables. The encoder exploits the
Expand All @@ -125,8 +132,9 @@ different results.

On the repo's test textures this lands within a few tenths of a dB of the
per-block CPU reference encoders (`gputex/testing`) and above them on
several (BC5 matches exactly; BC1 on flat content, BC7 and ASTC on some
maps measure above), trailing only on adversarial high-frequency noise,
several (BC5 matches exactly; BC1 on flat content and ASTC on some maps
measure above; BC7, whose reference searches mode 6 only, beats it by 2–8 dB
wherever mode 4 applies), trailing only on adversarial high-frequency noise,
where any single-line seed loses to an exhaustive search — while encoding
an order of magnitude faster. See the benchmark table below.

Expand Down Expand Up @@ -387,7 +395,7 @@ shader alone.
| BC5 | f16 (default) | **0.14 ms** |
| BC5 | f32 | 0.15 ms |
| BC7 | f16 (default) | **0.19 ms** |
| BC7 | f32 | 0.52 ms |
| BC7 | f32 | 0.41 ms |
| ASTC 4×4 | f16 (default) | **0.17 ms** |
| ASTC 4×4 | f32 | 0.28 ms |
| ETC2 | f16 (default) | **0.14 ms** |
Expand Down
44 changes: 24 additions & 20 deletions example/lib/gpuTestSuite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,40 +109,44 @@ const PSNR_THRESHOLDS: Record<string, number | null> = {
// pass-1 partition + regression refit + offset round, lossless spans ≤ 7):
// measured normal 53.39, rock-normal 1k/2k/4k 46.68/45.23/44.09, wood-
// normal 1k/2k/4k 48.46/49.38/47.98 (min f16/f32) — +0.18..0.30 dB over
// the CPU reference on every ref-gated row. BC7 rows on multi-modal content (packed-*, rock-color) sit at
// the mode-6 exhaustive reference level — the mode 1 candidate that
// lifted them ~+1.3 dB was dropped for speed (see bc7_fast_f16.wgsl).
// Unlisted rows and the `:normal` colour-format cross-card entries are
// record-only.
// the CPU reference on every ref-gated row. BC7 colour rows re-pinned
// 2026-09 for the always-on mode 6 / mode 4 kernel (per-block covariance
// decision): measured color 35.99, alpha 46.21, packed-256/512/1024/2048/
// 4096 37.34/39.58/41.87/46.48/50.40, rock-color-1k/2k/4k 40.52/40.55/
// 40.41, wood-color-1k/2k/4k 50.16/50.72/51.81 (min f16/f32) — +2..8 dB
// over the mode-6 exhaustive CPU reference on decorrelated content;
// the gray rows take the unchanged gray tail. Unlisted rows and the
// `:normal` colour-format cross-card entries are record-only.
// BC1 rows re-pinned 2026-09 (solid-colour path for near-flat blocks +
// moment-form refit passes): measured color 29.32, rock-ao 41.63, rock-
// displacement 45.85, wood-roughness 40.64, wood-displacement 43.36 (min
// f16/f32).
'bc1:color': 29.17,
'bc5:normal': 53.2,
'bc7:color': 31.9,
'bc7:color': 35.84,
// ASTC colour rows re-pinned 2026-09 for the opaque CEM 8 bit budgets
// (QUANT_192 endpoints + 4-bit weights on wide blocks): measured
// 32.03 / 37.83 / 38.16 / 50.88 (color / packed / rock / wood, min
// f16/f32), up from 31.97 / 37.59 / 37.52 / 49.60.
'astc:color': 31.88,
// 1024² committed alpha card (2026-07): bc7 38.43/38.38 f16/f32, astc
// 37.28/37.27 — both above the exhaustive reference on this content.
'bc7:alpha': 38.2,
// 1024² committed alpha card: bc7 46.21/46.23 f16/f32 (2026-09, mode 4
// splits the independent alpha into its own plane), astc 37.28/37.27
// (2026-07) — both above the exhaustive reference on this content.
'bc7:alpha': 46.06,
'astc:alpha': 37.1,
'bc1:normal': null,
'bc7:normal': null,
'astc:normal': null,
// Real textures (2026-07 baselines, min over f16/f32, minus ~0.15 dB).
'bc7:packed-256': 32.75,
'bc7:packed-512': 35.0,
'bc7:packed-256': 37.18,
'bc7:packed-512': 39.43,
'bc1:packed-1024': 34.75,
'bc7:packed-1024': 37.65,
'bc7:packed-1024': 41.71,
'astc:packed-1024': 37.68,
'bc7:packed-2048': 45.15,
'bc7:packed-4096': 49.95,
'bc7:packed-2048': 46.32,
'bc7:packed-4096': 50.25,
'bc1:rock-color-1k': 33.9,
'bc7:rock-color-1k': 38.1,
'bc7:rock-color-1k': 40.36,
'astc:rock-color-1k': 38.0,
'bc5:rock-normal-1k': 46.5,
'bc1:rock-roughness-1k': 39.1,
Expand All @@ -165,12 +169,12 @@ const PSNR_THRESHOLDS: Record<string, number | null> = {
'astc:rock-displacement-1k': 70.4,
'astc:wood-roughness-1k': 59.85,
'astc:wood-displacement-1k': 65.1,
'bc7:rock-color-2k': 38.75,
'bc7:rock-color-2k': 40.4,
'bc5:rock-normal-2k': 45.05,
'bc7:rock-color-4k': 39.15,
'bc7:rock-color-4k': 40.26,
'bc5:rock-normal-4k': 43.9,
'bc1:wood-color-1k': 41.9,
'bc7:wood-color-1k': 49.4,
'bc7:wood-color-1k': 50.0,
'astc:wood-color-1k': 50.7,
// ETC2 (observed minus ~0.15 dB). The 2026-09 speed rewrite (gather
// loads, register-resident lumas, gray-only dual-flip scoring) is ~1.7×
Expand All @@ -188,9 +192,9 @@ const PSNR_THRESHOLDS: Record<string, number | null> = {
'bc5:wood-normal-1k': 48.3,
'bc1:wood-roughness-1k': 40.49,
'bc1:wood-displacement-1k': 43.21,
'bc7:wood-color-2k': 50.65,
'bc7:wood-color-2k': 50.57,
'bc5:wood-normal-2k': 49.2,
'bc7:wood-color-4k': 51.7,
'bc7:wood-color-4k': 51.66,
'bc5:wood-normal-4k': 47.8,
}

Expand Down
18 changes: 18 additions & 0 deletions example/pages/eval.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ interface Row {
/** The block behind maxEasyWorse: source RGBA bytes (row-major) and both encodings. */
maxEasyBlock?: { index: number; bx: number; by: number; src: number[]; base: number[]; mine: number[] }
identicalPct: number
/** BC7 only: block count per mode (mode 4: m4r<rotation>i<idxMode>, mode 5: m5r<rotation>). */
modeHist?: Record<string, number>
}

interface ImageResult {
Expand Down Expand Up @@ -170,6 +172,21 @@ const fmtOf = (name: string): Fmt => {
throw new Error(`${name}: cannot infer the format from the name (want a bc1/bc5/bc7/astc/etc2 prefix)`)
}

/** BC7 mode histogram (mode = index of the lowest set bit of byte 0). */
function bc7ModeHist(bytes: Uint8Array, blocks: number): Record<string, number> {
const h: Record<string, number> = {}
for (let b = 0; b < blocks; b++) {
const b0 = bytes[b * 16]!
let mode = 0
while (mode < 8 && !((b0 >> mode) & 1)) mode++
let key = `m${mode}`
if (mode === 4) key += `r${(b0 >> 5) & 3}i${b0 >> 7}`
else if (mode === 5) key += `r${(b0 >> 6) & 3}`
h[key] = (h[key] ?? 0) + 1
}
return h
}

const psnrOf = (sse: number, n: number): number => (sse === 0 ? Infinity : 10 * Math.log10((255 * 255 * n) / sse))

const median = (xs: number[]): number => {
Expand Down Expand Up @@ -584,6 +601,7 @@ async function runEval(onProgress: (msg: string) => void): Promise<{ results: Im
return { index: maxEasyIdx, bx: bxi, by: byi, src, base: slice(b0), mine: slice(bi) }
})(),
identicalPct: (100 * identical) / blocks,
modeHist: fmt === 'bc7' ? bc7ModeHist(bi, blocks) : undefined,
}
})
for (const r of rows) {
Expand Down
64 changes: 15 additions & 49 deletions library/src/BC7Encoder.ts
Original file line number Diff line number Diff line change
@@ -1,65 +1,31 @@
// BC7 GPU encoder.
//
// BC7 = 16 bytes per block, multi-mode RGBA block compression. This
// implementation produces mode 6 blocks only (single subset, RGBA,
// 4-bit indices, 7777.1 endpoints). Mode 6 is the strongest BC7 mode
// on smooth content — endpoints are near-8-bit and the 16-entry palette
// tracks a single data-line tightly. It degrades on multi-modal blocks
// (sharp colour seams, channel-packed atlases) where mode 1's two-subset
// partitioning would help; a mode 1 candidate was built and measured
// (~+1.3 dB there) but its evaluation cost up to ~3× the compute pass on
// exactly that content, so it was dropped in favour of speed — see the
// postmortem note in bc7_fast_f16.wgsl.
// implementation picks, per block, between two single-subset modes:
// • mode 6 — one RGBA line, 16 levels, near-8-bit endpoints: the best
// single-subset mode when a block's colours lie near one line;
// • mode 4 — one channel split off into its own scalar plane (a
// "rotation"), the other three on a line, with 2-bit and 3-bit index
// sets: decorrelated data (normal maps, channel-packed atlases, noisy
// photo chroma, independent alpha) where any single 4-D line fails.
// The choice is made from the block's covariance before encoding and both
// modes share the same per-pixel passes, so mixing them costs no extra GPU
// time (×0.98 geomean vs the former mode-6-only kernel on the /eval
// corpus, MSE −33% geomean). Always on — there is no quality toggle. See
// the header of bc7_fast_f16.wgsl for the design and the measurements.
//
// OPT-IN: `adaptiveMode4: true` additionally emits MODE 4 blocks
// (rotation: the worst-explained channel gets its own 3-bit-index scalar
// plane) on decorrelated content — +2.5–2.9 dB on tangent-space normal
// maps, +1.9–2.4 dB on channel-packed atlases, +3 dB on hue-edge-heavy
// content. It is off by default because any GPU warp holding a single
// mode-4 block executes both modes' passes: content that benefits encodes
// 1.4–1.5× slower, and no per-block gate can avoid it (quality and warp
// poisoning scale together — measurements in bc7_fast_f16.wgsl). The
// toggle is a WebGPU pipeline override constant, so the default-off path
// is dead-coded at pipeline creation and costs exactly nothing.
// f16-only: the f32/WebGL fallbacks always encode mode 6.
//
// Algorithm lives in `bc7.wgsl`; CPU reference + tests are in
// `bc7_ref.ts` and its test file.
// Algorithm lives in `bc7_fast_f16.wgsl` (f16) and `bc7.wgsl` (f32
// fallback); CPU reference + tests are in `bc7_ref.ts` and its test file.

import shaderSource from './bc7.wgsl'
import shaderSourceF16 from './bc7_fast_f16.wgsl'
import { Encoder, type EncoderOptions, type FormatVariant } from './Encoder.js'
import { Encoder, type FormatVariant } from './Encoder.js'
import { TextureFormat, WebGPUFeature } from './TextureFormat.js'

export interface BC7EncoderOptions extends EncoderOptions {
/**
* Emit BC7 mode 4 on decorrelated blocks (normal maps, channel-packed
* atlases): a large quality win there, at up to ~1.5× encode time on
* exactly that content. See the header note for the measurements.
*/
adaptiveMode4?: boolean
}

export class BC7Encoder extends Encoder {
static override readonly requiredFeature: GPUFeatureName = WebGPUFeature.BC
static readonly textureFormats: readonly TextureFormat[] = [TextureFormat.BC7, TextureFormat.BC7_SRGB]

private readonly adaptiveMode4: boolean

constructor(opts: BC7EncoderOptions) {
// The base constructor builds the pipeline, so stash the flag first
// via a property on the options object read back in pipelineConstants.
super(opts)
this.adaptiveMode4 = opts.adaptiveMode4 === true
if (this.adaptiveMode4) this._buildPipeline()
}

protected override pipelineConstants(): Record<string, number> | undefined {
// f32 fallback has no mode-4 path (and no such constant).
if (this.adaptiveMode4 && this._useF16) return { enable_mode4: 1 }
return undefined
}

override get label(): string {
return 'bc7'
}
Expand Down
16 changes: 2 additions & 14 deletions library/src/Encoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,11 +308,10 @@ export abstract class Encoder {
label: `${this.label}-encoder${useF16 ? '-f16' : ''}`,
code,
})
const constants = this.pipelineConstants()
this._pipelineReady = device.createComputePipelineAsync({
label: `${this.label}-encoder-pipeline${useF16 ? '-f16' : ''}`,
layout: 'auto',
compute: { module, entryPoint: 'encode', ...(constants ? { constants } : {}) },
compute: { module, entryPoint: 'encode' },
})
const prepCode = this.wgslPrepSource()
if (prepCode) {
Expand All @@ -324,8 +323,7 @@ export abstract class Encoder {
})
this._prepPipelineReady.catch(() => {})
}
// An encoder may be constructed and never used, or rebuilt before first
// use (BC7's adaptiveMode4 constructor path) — don't let an orphaned
// An encoder may be constructed and never used — don't let an orphaned
// promise surface as an unhandled rejection. Encodes await the live
// promise and still observe compile errors.
this._pipelineReady.catch(() => {})
Expand Down Expand Up @@ -382,16 +380,6 @@ export abstract class Encoder {
/** 8 for BC1/BC4, 16 for BC5/BC7/ASTC 4×4. */
abstract get bytesPerBlock(): number

/**
* Pipeline-creation override constants for the active shader, or
* undefined for none. Subclasses expose quality/behaviour toggles this
* way so the OFF state is dead-coded by the shader compiler instead of
* branched at runtime.
*/
protected pipelineConstants(): Record<string, number> | undefined {
return undefined
}

/** WGSL `@workgroup_size` dimensions. Default 8×8×1. */
get workgroupSize(): readonly [number, number, number] {
return [8, 8, 1]
Expand Down
4 changes: 2 additions & 2 deletions library/src/__tests__/encoders.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,9 @@ describe('BC7Encoder', () => {
expect(src).toContain('@compute')
expect(src).toContain('@workgroup_size')
// Sanity: the shader exposes the BC7-specific helpers by name.
expect(src).toContain('principal_axis4')
expect(src).toContain('pick_ep')
expect(src).toContain('compact3')
expect(src).toMatch(/Mode 6/i)
expect(src).toMatch(/Mode 4/i)
})
})

Expand Down
Loading
Loading