fix(viewer): survive block-misaligned KTX2 textures on WebGPU (#467)

WebGPU rejects block-compressed textures whose base dimensions aren't
multiples of 4; three's KTX2Loader only warns and transcodes anyway, so a
baked GLB carrying one odd-sized user-item texture (e.g. 299x399) poisoned
every render pass — endless 'Invalid BindGroup … Invalid CommandBuffer'
spam. Route such payloads through a fallback transcode to uncompressed
RGBA32 and repackage the pixels as a plain DataTexture (three's WebGPU
upload path crashes on a CompressedTexture holding rgba8 data).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-07-06 19:39:52 -04:00
committed by GitHub
co-authored by Claude Fable 5
parent f6ffb93a39
commit f6a58489b8
+108 -1
View File
@@ -1,5 +1,112 @@
import {
type CompressedTexture,
DataTexture,
LinearFilter,
LinearMipmapLinearFilter,
RGBAFormat,
UnsignedByteType,
} from 'three'
import { KTX2Loader } from 'three/examples/jsm/Addons.js'
/** The private KTX2Loader surface this module relies on (stable across three
* releases but not part of the public types). */
type KTX2LoaderInternals = {
transcoderPath: string
workerConfig: Record<string, boolean> | null
_createTexture(buffer: ArrayBuffer, config?: Record<string, unknown>): Promise<unknown>
}
/** Base width/height of a Basis-supercompressed KTX2 payload needing transcode
* (vkFormat 0 = ETC1S/UASTC) whose dimensions aren't block-aligned, or null
* for anything safe. Little-endian u32s: vkFormat at byte 12, pixelWidth at
* 20, pixelHeight at 24. */
function readMisalignedBasisSize(buffer: ArrayBuffer): { width: number; height: number } | null {
if (buffer.byteLength < 28) return null
const view = new DataView(buffer)
const vkFormat = view.getUint32(12, true)
if (vkFormat !== 0) return null
const width = view.getUint32(20, true)
const height = view.getUint32(24, true)
return width % 4 !== 0 || height % 4 !== 0 ? { width, height } : null
}
/**
* KTX2Loader that survives block-misaligned textures. WebGPU rejects
* block-compressed textures whose base dimensions aren't multiples of 4;
* three's KTX2Loader only warns and transcodes anyway, and the resulting
* invalid texture poisons every render pass that binds it (endless
* "Invalid BindGroup … Invalid CommandBuffer" spam — baked GLBs carrying an
* odd-sized user-item texture triggered exactly this). Such payloads are
* routed to a fallback loader whose support flags report no compressed
* formats, which makes the transcoder emit plain RGBA32 — uncompressed
* textures have no alignment requirement.
*/
class AlignmentSafeKTX2Loader extends KTX2Loader {
private rgbaFallback: KTX2Loader | null = null
private fallbackLoader(): KTX2LoaderInternals {
if (!this.rgbaFallback) {
this.rgbaFallback = new KTX2Loader(this.manager)
this.rgbaFallback.setTranscoderPath((this as unknown as KTX2LoaderInternals).transcoderPath)
// All formats unsupported → getTranscoderFormat falls through to RGBA32.
;(this.rgbaFallback as unknown as KTX2LoaderInternals).workerConfig = {
astcSupported: false,
astcHDRSupported: false,
etc1Supported: false,
etc2Supported: false,
dxtSupported: false,
bptcSupported: false,
pvrtcSupported: false,
}
}
return this.rgbaFallback as unknown as KTX2LoaderInternals
}
/** Both `load()` and GLTFLoader's KHR_texture_basisu path funnel through
* this internal — overriding it covers every entry point. */
async _createTexture(buffer: ArrayBuffer, config?: Record<string, unknown>): Promise<unknown> {
const misaligned = readMisalignedBasisSize(buffer)
if (misaligned) {
console.warn(
`[viewer] KTX2 texture is ${misaligned.width}x${misaligned.height} (not multiple-of-4); ` +
'transcoding uncompressed so WebGPU can create it.',
)
const transcoded = (await this.fallbackLoader()._createTexture(
buffer,
config,
)) as CompressedTexture
// The RGBA32 result still arrives as a CompressedTexture; three's WebGPU
// texture upload treats every CompressedTexture as block-compressed and
// crashes looking up a block descriptor for rgba8. Repackage the decoded
// pixels as a plain DataTexture instead.
const mip = (
transcoded.mipmaps as Array<{ data: Uint8Array; width: number; height: number }>
)?.[0]
if (!mip) return transcoded
const texture = new DataTexture(mip.data, mip.width, mip.height, RGBAFormat, UnsignedByteType)
texture.colorSpace = transcoded.colorSpace
texture.generateMipmaps = true
texture.minFilter = LinearMipmapLinearFilter
texture.magFilter = LinearFilter
texture.needsUpdate = true
transcoded.dispose()
return texture
}
return (KTX2Loader.prototype as unknown as KTX2LoaderInternals)._createTexture.call(
this,
buffer,
config,
)
}
dispose(): this {
this.rgbaFallback?.dispose()
this.rgbaFallback = null
super.dispose()
return this
}
}
/**
* Single shared KTX2 loader for the whole viewer — used both by the GLB loader
* (`use-gltf-ktx2`) and by catalog finish textures (`materials.ts`). KTX2 must
@@ -9,7 +116,7 @@ import { KTX2Loader } from 'three/examples/jsm/Addons.js'
* from the viewer root the moment the renderer is ready (even when no GLB is in
* the scene, so catalog `.ktx2` finishes still load).
*/
export const ktx2Loader = new KTX2Loader()
export const ktx2Loader = new AlignmentSafeKTX2Loader()
ktx2Loader.setTranscoderPath('https://cdn.jsdelivr.net/gh/pmndrs/drei-assets@master/basis/')
const configuredRenderers = new WeakSet<object>()