1// Shader Deblocking â WebGL 2 browser port of the native shader_deblocking_glfw sample. 2// Copyright (C) 2026 Binomial LLC. LICENSE: Apache 2.0 3// 4// Loads a Basis Universal .KTX2 (via the WASM transcoder), transcodes every mip 5// level to a GPU format (ASTC -> BC7 -> ETC1/ETC2, falling back to uncompressed 6// RGBA32), and renders it on a textured quad or cube with the mipmap-aware 7// deblocking pixel shader (GLSL ES 3.00 / #version 300 es). 8// 9// Controls: arrows move, W/S zoom, A/D yaw, Q/E pitch, C cube/quad, B/T/P filter, 10// 1 deblock on/off, 2 edge visualization, 3-8 spare consts, Space reset. 11 12'use strict'; 13 14// --------------------------------------------------------------------------- 15// Tunables (mirror the native sample's constants). 16// --------------------------------------------------------------------------- 17var FOV_DEGREES = 90.0; 18var Z_NEAR = 0.001, Z_FAR = 100.0; 19var Z_MIN = 0.40, Z_MAX = -50.0; // camera-z clamp range (z is negative going away) 20var Z_SPEED = 1.0, XY_SPEED = 0.75, ROT_SPEED = 90.0; 21var DEFAULT_ASSET = 'assets/kodim23.ktx2'; 22 23// --------------------------------------------------------------------------- 24// Shaders â the native bin/shader.glsl ported to #version 300 es. The deblock 25// body is byte-identical to the desktop version; only the version line and the 26// fragment precision qualifiers differ (dFdx/dFdy are core in GLSL ES 3.00). 27// NOTE: "#version 300 es" MUST be the very first characters of the source. 28// --------------------------------------------------------------------------- 29var VERTEX_SHADER_SRC = `#version 300 es 30layout(location = 0) in vec3 aPos; 31layout(location = 1) in vec2 aUV; 32 33uniform mat4 mvp; 34 35out vec2 vUV; 36 37void main() { 38 vUV = aUV; 39 gl_Position = mvp * vec4(aPos, 1.0); 40} 41`; 42 43var FRAGMENT_SHADER_SRC = `#version 300 es 44precision highp float; 45precision highp sampler2D; 46 47uniform sampler2D tex; 48uniform vec4 texSize; // xy = base mip (mip0) dims, zw = deblock block size in texels 49uniform float maxLod; // number_of_mip_levels - 1 50 51uniform vec4 const0; // x = deblock on, y = edge-weight viz, z/w spare 52uniform vec4 const1; // spare user constants 53 54in vec2 vUV; 55out vec4 fragColor; 56 57void main() 58{ 59 vec2 texDim = vec2(texSize.x, texSize.y); 60 vec2 blockSize = vec2(texSize.z, texSize.w); 61 62 // Recover the effective mip level from screen-space UV derivatives, so the 63 // deblock lattice tracks whichever mip the hardware is actually sampling. 64 vec2 du = dFdx(vUV); 65 vec2 dv = dFdy(vUV); 66 float rho = max(length(du * texSize.xy), length(dv * texSize.xy)); 67 float lod = clamp(log2(max(rho, 1e-8)), 0.0, maxLod); 68 float mipScale = exp2(floor(lod + 0.5)); // snap to dominant mip (1=mip0, 2=mip1, ...) 69 70 vec2 texelStep = mipScale / texDim; // one texel in effective mip space 71 vec2 texelPos = (vUV * texDim) / mipScale; // texel coord in effective mip space 72 vec2 blockPos = mod(texelPos, blockSize); // offset within the current block 73 74 vec3 color = texture(tex, vUV).rgb; 75 76 // Keep these fetches outside non-uniform control flow: texture() uses implicit 77 // derivatives for LOD, which are undefined when neighboring fragments take 78 // different branches. Use base sampling so edgeWeight can safely go to zero. 79 vec3 l1 = texture(tex, vUV - vec2(texelStep.x, 0.0)).rgb; 80 vec3 r1 = texture(tex, vUV + vec2(texelStep.x, 0.0)).rgb; 81 vec3 u1 = texture(tex, vUV - vec2(0.0, texelStep.y)).rgb; 82 vec3 d1 = texture(tex, vUV + vec2(0.0, texelStep.y)).rgb; 83 84 if (const0.x > 0.5) 85 { 86 const float falloff = 1.5; 87 88 float leftProx = 1.0 - clamp(blockPos.x / falloff, 0.0, 1.0); 89 float rightProx = 1.0 - clamp((blockSize.x - blockPos.x) / falloff, 0.0, 1.0); 90 float topProx = 1.0 - clamp(blockPos.y / falloff, 0.0, 1.0); 91 float bottomProx = 1.0 - clamp((blockSize.y - blockPos.y) / falloff, 0.0, 1.0); 92 93 float horizWeight = max(leftProx, rightProx); 94 float vertWeight = max(topProx, bottomProx); 95 float edgeWeight = max(horizWeight, vertWeight); 96 97 if (edgeWeight > 0.0) 98 { 99 vec3 c0 = color; 100 vec3 filteredH = (l1 + c0 + r1) * (1.0 / 3.0); 101 vec3 filteredV = (u1 + c0 + d1) * (1.0 / 3.0); 102 103 float strengthH = horizWeight; 104 float strengthV = vertWeight; 105 106 vec3 horizColor = mix(c0, filteredH, strengthH); 107 vec3 vertColor = mix(c0, filteredV, strengthV); 108 109 float totalW = strengthH + strengthV; 110 if (totalW > 0.0) 111 color = (horizColor * strengthH + vertColor * strengthV) / totalW; 112 } 113 114 // Block-edge weight visualization (key 2). 115 if (const0.y > 0.5) 116 color = vec3(edgeWeight, edgeWeight, edgeWeight); 117 } 118 119 fragColor = vec4(color, 1.0); 120} 121`; 122
123// --------------------------------------------------------------------------- 124// GPU format constants + transcoder_texture_format enum (subset; LDR only). 125// --------------------------------------------------------------------------- 126// WEBGL_compressed_texture_astc internal formats keyed by literal (w,h). The GL 127// enum order is W-major and is NOT the same as Basis's area-ordered transcoder 128// enum (note 8x8 sits out of numeric order), so we ALWAYS map by (w,h), never by 129// an ordinal. 130// 131// IMPORTANT: we deliberately use the LINEAR (non-sRGB) ASTC enums (0x93B0..), NOT 132// the sRGB variants (base + 0x20). This shader writes the sampled texel straight 133// to the canvas with no linear->sRGB encode, so the stored (already-sRGB-encoded) 134// bytes must be sampled verbatim. An sRGB internal format would make the hardware 135// decode sRGB->linear on sample, and the un-encoded linear output then displays 136// far too dark. BC7 (UNORM) and the native C++ sample behave the same way. 137var ASTC_GL = { 138 '4,4': 0x93B0, '5,4': 0x93B1, '5,5': 0x93B2, '6,5': 0x93B3, '6,6': 0x93B4, 139 '8,5': 0x93B5, '8,6': 0x93B6, '8,8': 0x93B7, '10,5': 0x93B8, '10,6': 0x93B9, 140 '10,8': 0x93BA, '10,10': 0x93BB, '12,10': 0x93BC, '12,12': 0x93BD 141}; 142// Matching transcoder_texture_format values (Module.transcoder_texture_format). 143var ASTC_TF = { 144 '4,4': 10, '5,4': 28, '5,5': 29, '6,5': 30, '6,6': 31, '8,5': 32, '8,6': 33, 145 '10,5': 34, '10,6': 35, '8,8': 36, '10,8': 37, '10,10': 38, '12,10': 39, '12,12': 40 146}; 147var TF_BC7 = 6, TF_ETC1 = 0, TF_RGBA32 = 13; 148var GL_COMPRESSED_RGBA_BPTC_UNORM = 0x8E8C; 149var GL_COMPRESSED_RGB_ETC1_WEBGL = 0x8D64; // ETC1 is RGB-only (alpha dropped); fine for these LDR photos 150var GL_COMPRESSED_RGB8_ETC2 = 0x9274; // ETC2 RGB (superset of ETC1) â used when only the ETC2 ext is present 151 // (linear, non-sRGB â see the ASTC note above for why) 152 153function astcGLName(fmt) { 154 for (var k in ASTC_GL) { if (ASTC_GL[k] === fmt) return 'ASTC_' + k.replace(',', 'x'); } 155 if (ASTC_GL['4,4'] + 0x20 <= fmt && fmt <= ASTC_GL['12,12'] + 0x20) { 156 for (var k2 in ASTC_GL) { if (ASTC_GL[k2] + 0x20 === fmt) return 'SRGB_ASTC_' + k2.replace(',', 'x'); } 157 } 158 return '0x' + fmt.toString(16); 159} 160 161// --------------------------------------------------------------------------- 162// Column-major 4x4 matrices (WebGL uniformMatrix4fv requires transpose=false, 163// so we build column-major directly â the native sample was row-major + GL_TRUE). 164// --------------------------------------------------------------------------- 165// All matrix builders write into a caller-provided Float32Array `o` (and return 166// it), so draw() can reuse module-scope scratch buffers instead of allocating a 167// fresh matrix every frame (avoids ~360 allocs/sec of GC churn on mobile). 168function mat4Identity(o) { 169 o[0] = 1; o[1] = 0; o[2] = 0; o[3] = 0; 170 o[4] = 0; o[5] = 1; o[6] = 0; o[7] = 0; 171 o[8] = 0; o[9] = 0; o[10] = 1; o[11] = 0; 172 o[12] = 0; o[13] = 0; o[14] = 0; o[15] = 1; 173 return o; 174} 175function mat4Multiply(a, b, o) { // o = a*b; o must not alias a or b 176 for (var c = 0; c < 4; c++) 177 for (var r = 0; r < 4; r++) { 178 var s = 0; 179 for (var k = 0; k < 4; k++) s += a[k * 4 + r] * b[c * 4 + k]; 180 o[c * 4 + r] = s; 181 } 182 return o; 183} 184function mat4Perspective(fovDeg, aspect, near, far, o) { 185 var f = 1.0 / Math.tan((fovDeg * Math.PI / 180.0) / 2.0); 186 for (var i = 0; i < 16; i++) o[i] = 0; 187 o[0] = f / aspect; 188 o[5] = f; 189 o[10] = (far + near) / (near - far); 190 o[11] = -1.0; 191 o[14] = (2.0 * far * near) / (near - far); 192 return o; 193} 194function mat4Translate(x, y, z, o) { 195 mat4Identity(o); o[12] = x; o[13] = y; o[14] = z; return o; 196} 197function mat4RotX(deg, o) { 198 var r = deg * Math.PI / 180.0, c = Math.cos(r), s = Math.sin(r); 199 mat4Identity(o); o[5] = c; o[6] = s; o[9] = -s; o[10] = c; return o; 200} 201function mat4RotY(deg, o) { 202 var r = deg * Math.PI / 180.0, c = Math.cos(r), s = Math.sin(r); 203 mat4Identity(o); o[0] = c; o[2] = -s; o[8] = s; o[10] = c; return o; 204} 205 206// --------------------------------------------------------------------------- 207// Global state. 208// --------------------------------------------------------------------------- 209var gl = null; 210var canvas = null; 211var Module = null; 212 213var prog = null; // { program, uniforms{}, attribs{} } 214var caps = { astc: false, bc7: false, etc1: false, etc2: false }; 215var astcExt = null, bptcExt = null, etc1Ext = null, etcExt = null; 216 217var quad = null; // { vao, count } 218var cube = null; // { vao, count } 219var texture = null; 220 221var g = { 222 x: 0, y: 0, z: -0.4, yaw: 0, pitch: 0, 223 cube: false, 224 filterMode: 2, // 0=point, 1=bilinear, 2=trilinear (native startup = trilinear) 225 const0: [0, 0, 0, 0], 226 const1: [0, 0, 0, 0] 227}; 228var initConst0 = [0, 0, 0, 0]; 229 230// Info about the currently loaded texture (for the overlay). 231var texInfo = { 232 w: 0, h: 0, levels: 1, blockW: 4, blockH: 4, 233 deblockId: 0, srcFmt: '-', gpuFmt: '-' 234}; 235 236var heldKeys = {}; 237var lastTime = 0; 238// Reused scratch matrices for draw() (see mat4* note) â avoids per-frame allocation. 239var _proj = new Float32Array(16), _trans = new Float32Array(16); 240var _rotY = new Float32Array(16), _rotX = new Float32Array(16), _rot = new Float32Array(16); 241var _model = new Float32Array(16), _mvp = new Float32Array(16); 242var rafId = 0; // requestAnimationFrame handle (so we can stop/restart on context loss) 243var currentUrl = DEFAULT_ASSET; // last-requested texture URL (for context-restore reload) 244var loadGen = 0; // monotonic token: newest load wins, stale loads bail out 245var loading = false; // drives the "Loadingâ¦" overlay during a transcode 246 247// --------------------------------------------------------------------------- 248// GL helpers. 249// --------------------------------------------------------------------------- 250function compileShader(src, type) { 251 var sh = gl.createShader(type); 252 gl.shaderSource(sh, src); 253 gl.compileShader(sh); 254 if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) { 255 var log = gl.getShaderInfoLog(sh); 256 console.error((type === gl.VERTEX_SHADER ? 'VERTEX' : 'FRAGMENT') + ' shader error:\n' + log); 257 gl.deleteShader(sh); 258 throw new Error('Shader compile failed'); 259 } 260 return sh; 261} 262function buildProgram(vsSrc, fsSrc) { 263 var program = gl.createProgram(); 264 gl.attachShader(program, compileShader(vsSrc, gl.VERTEX_SHADER)); 265 gl.attachShader(program, compileShader(fsSrc, gl.FRAGMENT_SHADER)); 266 gl.linkProgram(program); 267 if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { 268 console.error('Program link failed:\n' + gl.getProgramInfoLog(program)); 269 throw new Error('Program link failed'); 270 } 271 var uniforms = {}; 272 var nu = gl.getProgramParameter(program, gl.ACTIVE_UNIFORMS); 273 for (var i = 0; i < nu; i++) { 274 var ui = gl.getActiveUniform(program, i); 275 uniforms[ui.name] = gl.getUniformLocation(program, ui.name); 276 } 277 var attribs = {}; 278 var na = gl.getProgramParameter(program, gl.ACTIVE_ATTRIBUTES); 279 for (var j = 0; j < na; j++) { 280 var ai = gl.getActiveAttrib(program, j); 281 attribs[ai.name] = gl.getAttribLocation(program, ai.name); 282 } 283 return { program: program, uniforms: uniforms, attribs: attribs }; 284} 285 286// Interleaved [pos.xyz, uv.xy] mesh -> VAO. Attribute locations match the 287// shader's layout(location=...) (0 = pos, 1 = uv). 288function makeMesh(verts, indices) { 289 var vao = gl.createVertexArray(); 290 gl.bindVertexArray(vao); 291 292 var vbo = gl.createBuffer(); 293 gl.bindBuffer(gl.ARRAY_BUFFER, vbo); 294 gl.bufferData(gl.ARRAY_BUFFER, verts, gl.STATIC_DRAW); 295 296 var ebo = gl.createBuffer(); 297 gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ebo); 298 gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW); 299 300 var stride = 5 * 4; 301 gl.enableVertexAttribArray(0); 302 gl.vertexAttribPointer(0, 3, gl.FLOAT, false, stride, 0); 303 gl.enableVertexAttribArray(1); 304 gl.vertexAttribPointer(1, 2, gl.FLOAT, false, stride, 3 * 4); 305 306 gl.bindVertexArray(null); 307 // Keep the VBO/EBO handles: deleting the VAO does NOT free its buffer objects 308 // in WebGL, so freeMesh() must delete all three or they leak on every reload. 309 return { vao: vao, vbo: vbo, ebo: ebo, count: indices.length }; 310} 311 312// Free a mesh's VAO and its owned VBO/EBO. Safe to call with null. 313function freeMesh(m) { 314 if (!m) return; 315 gl.deleteVertexArray(m.vao); 316 gl.deleteBuffer(m.vbo); 317 gl.deleteBuffer(m.ebo); 318} 319 320// Quad fitted to the texture aspect (UVs v-flipped: bottom row v=1, top v=0). 321function createQuad(aspect) { 322 var hw, hh;
323 if (aspect >= 1.0) { hw = 1.0; hh = 1.0 / aspect; } else { hw = aspect; hh = 1.0; } 324 var v = new Float32Array([ 325 -hw, -hh, 0.0, 0.0, 1.0, 326 hw, -hh, 0.0, 1.0, 1.0, 327 hw, hh, 0.0, 1.0, 0.0, 328 -hw, hh, 0.0, 0.0, 0.0 329 ]); 330 var idx = new Uint16Array([0, 1, 2, 0, 2, 3]); 331 return makeMesh(v, idx); 332} 333 334// Unit cube (half-extent 0.5), same texture mapped 0..1 on all six faces. 335function createCube() { 336 var h = 0.5; 337 var v = new Float32Array([ 338 // front (z=+h) 339 -h, -h, h, 0, 1, h, -h, h, 1, 1, h, h, h, 1, 0, -h, h, h, 0, 0, 340 // back (z=-h) 341 h, -h, -h, 0, 1, -h, -h, -h, 1, 1, -h, h, -h, 1, 0, h, h, -h, 0, 0, 342 // right (x=+h) 343 h, -h, h, 0, 1, h, -h, -h, 1, 1, h, h, -h, 1, 0, h, h, h, 0, 0, 344 // left (x=-h) 345 -h, -h, -h, 0, 1, -h, -h, h, 1, 1, -h, h, h, 1, 0, -h, h, -h, 0, 0, 346 // top (y=+h) 347 -h, h, h, 0, 1, h, h, h, 1, 1, h, h, -h, 1, 0, -h, h, -h, 0, 0, 348 // bottom (y=-h) 349 -h, -h, -h, 0, 1, h, -h, -h, 1, 1, h, -h, h, 1, 0, -h, -h, h, 0, 0 350 ]); 351 var idx = new Uint16Array(36); 352 for (var i = 0; i < 6; i++) { 353 var b = i * 4, o = i * 6; 354 idx[o] = b; idx[o + 1] = b + 1; idx[o + 2] = b + 2; 355 idx[o + 3] = b; idx[o + 4] = b + 2; idx[o + 5] = b + 3; 356 } 357 return makeMesh(v, idx); 358} 359 360// --------------------------------------------------------------------------- 361// GPU format support detection (WebGL 2: compressed formats are still 362// extensions; compressedTexImage2D + NPOT mip chains are core). 363// --------------------------------------------------------------------------- 364function detectCaps() { 365 astcExt = gl.getExtension('WEBGL_compressed_texture_astc'); 366 bptcExt = gl.getExtension('EXT_texture_compression_bptc'); 367 etc1Ext = gl.getExtension('WEBGL_compressed_texture_etc1'); 368 etcExt = gl.getExtension('WEBGL_compressed_texture_etc'); // ETC2/EAC (GLES3-core; common on Android) 369 caps.astc = !!astcExt; 370 caps.bc7 = !!bptcExt; 371 caps.etc1 = !!etc1Ext; 372 caps.etc2 = !!etcExt; 373 console.log('GPU compressed support: ASTC=' + (caps.astc ? 1 : 0) + 374 ' BC7=' + (caps.bc7 ? 1 : 0) + ' ETC1=' + (caps.etc1 ? 1 : 0) + ' ETC2=' + (caps.etc2 ? 1 : 0)); 375} 376 377// Pick a GPU storage format via the ASTC(same block size) -> BC7 -> ETC1/ETC2 -> RGBA32 378// ladder, each rung gated on the GPU advertising it. 379// 380// IMPORTANT (D3D/ANGLE limitation): BC1-7 (and ETC1, also a 4x4-block format) 381// require the BASE mip level's width AND height to be multiples of 4. ANGLE/D3D 382// (Windows desktop browsers) rejects a non-multiple-of-4 base (e.g. 700x999) with 383// GL_INVALID_OPERATION, and there is no reliable way around it because the driver 384// derives the smaller mip dimensions itself. So when the base mip isn't 4-aligned 385// we skip the 4x4-block formats and fall back to uncompressed RGBA32 (which works 386// at any size). ASTC has no such restriction -- the hardware handles partial edge 387// blocks -- so it is used at any size, which is the common case on mobile. 388// NOTE: all rungs use LINEAR (non-sRGB) GPU internal formats on purpose â the shader 389// outputs sampled texels straight to the canvas, so the stored sRGB-encoded bytes must 390// be sampled verbatim (an sRGB internal format would decode to linear and display dark). 391function chooseFormat(bw, bh, w, h) { 392 var key = bw + ',' + bh; 393 if (caps.astc && ASTC_GL[key] !== undefined) { 394 var glf = ASTC_GL[key]; // linear enum (no +0x20 sRGB variant) â see note above 395 return { tf: ASTC_TF[key], glFmt: glf, compressed: true, name: astcGLName(glf) }; 396 } 397 var base4Aligned = (w % 4 === 0) && (h % 4 === 0); 398 if (caps.bc7 && base4Aligned) 399 return { tf: TF_BC7, glFmt: GL_COMPRESSED_RGBA_BPTC_UNORM, compressed: true, name: 'BC7' }; 400 // ETC path: always transcode to ETC1 (RGB). Upload via the native ETC1 GL format 401 // when WEBGL_compressed_texture_etc1 is present; otherwise via the ETC2-RGB enum 402 // (every ETC1 block is a valid ETC2 RGB block) â that's the extension most Android 403 // WebGL2 contexts actually expose, so probing both maximizes device coverage. 404 if ((caps.etc1 || caps.etc2) && base4Aligned) { 405 var etcGl = caps.etc1 ? GL_COMPRESSED_RGB_ETC1_WEBGL : GL_COMPRESSED_RGB8_ETC2; 406 return { tf: TF_ETC1, glFmt: etcGl, compressed: true, name: caps.etc1 ? 'ETC1' : 'ETC2_RGB' }; 407 } 408 // Uncompressed fallback: always valid, any dimensions. (Used when no GPU format 409 // is available, OR when the base mip isn't 4-aligned for the BC/ETC path.)
410 return { tf: TF_RGBA32, glFmt: gl.RGBA8, compressed: false, name: 'RGBA8' }; 411} 412 413// Best-effort source-format name for the overlay (matches native ktx2_format_name). 414function sourceFormatName(ktx2File, bw, bh) { 415 function has(m) { return typeof ktx2File[m] === 'function'; } 416 try { 417 if (has('isETC1S') && ktx2File.isETC1S()) return 'ETC1S'; 418 if (has('isXUASTC_LDR') && ktx2File.isXUASTC_LDR()) return 'XUASTC LDR ' + bw + 'x' + bh; 419 if (has('isASTC_LDR') && ktx2File.isASTC_LDR()) return 'ASTC LDR ' + bw + 'x' + bh; 420 if (has('isUASTC') && ktx2File.isUASTC()) return 'UASTC LDR 4x4'; 421 if (has('isXUBC7') && ktx2File.isXUBC7()) return 'XUBC7'; 422 } catch (e) { /* fall through */ } 423 return 'LDR ' + bw + 'x' + bh; 424} 425 426// --------------------------------------------------------------------------- 427// Texture loading: fetch .ktx2 -> transcode every mip level -> upload. 428// --------------------------------------------------------------------------- 429function loadKtx2(url) { 430 var myGen = ++loadGen; // newest load wins; older in-flight loads bail out below 431 currentUrl = url; 432 loading = true; // updateOverlay shows "Loadingâ¦" until this finishes 433 return fetch(url).then(function (resp) { 434 if (!resp.ok) throw new Error('Failed to fetch ' + url + ' (HTTP ' + resp.status + ')'); 435 return resp.arrayBuffer(); 436 }).then(function (buf) { 437 if (myGen !== loadGen) return; // a newer load started while we were fetching 438 uploadKtx2(new Uint8Array(buf), url); 439 loading = false; 440 }).catch(function (e) { 441 if (myGen === loadGen) loading = false; 442 showError(e.message || String(e)); 443 console.error(e); 444 }); 445} 446 447function uploadKtx2(bytes, url) { 448 // A fetch that started before a context loss can resolve after it but before the 449 // restore rebuilds â don't upload into a dead context (the restore's own reload 450 // will publish the texture). "Newest wins" across overlapping loads is already 451 // guaranteed by the loadGen check in loadKtx2 + uploadKtx2 running synchronously. 452 if (gl.isContextLost()) return; 453 454 var ktx2File = new Module.KTX2File(bytes); 455 var tex = null; // hoisted so the finally can free it if we throw before publishing 456 try { 457 if (!ktx2File.isValid()) throw new Error('Invalid .ktx2 file: ' + url); 458 if (ktx2File.isHDR()) throw new Error('HDR .ktx2 not supported (this sample is LDR-only): ' + url); 459 460 var w = ktx2File.getWidth(); 461 var h = ktx2File.getHeight(); 462 var levels = ktx2File.getLevels(); 463 var bw = ktx2File.getBlockWidth(); 464 var bh = ktx2File.getBlockHeight(); 465 var deblockId = ktx2File.getDeblockingFilterIndex(); 466 467 var fmt = chooseFormat(bw, bh, w, h); 468 469 if (!ktx2File.startTranscoding()) throw new Error('startTranscoding failed: ' + url); 470 471 var noDeblockFlag = Module.basisu_decode_flags.cDecodeFlagsNoDeblockFiltering.value; 472 473 // Build the new texture into a LOCAL first; the currently displayed texture/quad 474 // are only swapped out (and freed) after the upload fully succeeds. So a failed 475 // load leaves the previous image on screen instead of blanking it. (Holding two 476 // textures for the brief overlap is fine â the assets are small.) 477 tex = gl.createTexture(); 478 gl.bindTexture(gl.TEXTURE_2D, tex); 479 gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); 480 481 // Upload each mip level at its original (unpadded) dimensions. The 482 // transcoder emits ceil(w/4) x ceil(h/4) blocks with the edge blocks padded, 483 // and WebGL computes the expected byte size the same way, so the sizes match. 484 // Deep mips that floor to non-multiple-of-4 dims (e.g. 6x4, 3x2) are fine; 485 // only the BASE mip must be 4-aligned for BC/ETC1, which chooseFormat() 486 // already guaranteed (else it picked RGBA32). 487 for (var lvl = 0; lvl < levels; lvl++) { 488 var info = ktx2File.getImageLevelInfo(lvl, 0, 0); 489 var ow = info.origWidth, oh = info.origHeight; 490 var dstSize = ktx2File.getImageTranscodedSizeInBytes(lvl, 0, 0, fmt.tf); 491 var dst = new Uint8Array(dstSize); 492 // Pass the no-CPU-deblock flag: the GPU shader does the deblocking, so 493 // letting the transcoder also deblock would double-filter. 494 if (!ktx2File.transcodeImageWithFlags(dst, lvl, 0, 0, fmt.tf, noDeblockFlag, -1, -1)) 495 throw new Error('transcodeImageWithFlags failed (level ' + lvl + ')'); 496 497 if (fmt.compressed) 498 gl.compressedTexImage2D(gl.TEXTURE_2D, lvl, fmt.glFmt, ow, oh, 0, dst); 499 else 500 gl.texImage2D(gl.TEXTURE_2D, lvl, gl.RGBA8, ow, oh, 0, gl.RGBA, gl.UNSIGNED_BYTE, dst); 501 } 502 503 // One getError after the whole chain (each gl.getError() forces a CPU<->GPU 504 // sync, so we avoid doing it per level). The base mip is the usual culprit 505 // (e.g. a non-4-aligned BC base â guarded against in chooseFormat). 506 var err = gl.getError(); 507 if (err !== gl.NO_ERROR) 508 throw new Error('texture upload failed (' + w + 'x' + h + ' ' + fmt.name + 509 ', fmt 0x' + fmt.glFmt.toString(16) + '): GL 0x' + err.toString(16)); 510 511 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_BASE_LEVEL, 0); 512 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAX_LEVEL, levels - 1); 513 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); 514 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); 515 516 // Upload succeeded â now free the previous texture and swap in the new one. 517 if (texture) gl.deleteTexture(texture); 518 texture = tex; 519 texInfo = { 520 w: w, h: h, levels: levels, blockW: bw, blockH: bh, 521 deblockId: deblockId, 522 srcFmt: sourceFormatName(ktx2File, bw, bh), 523 gpuFmt: fmt.name 524 }; 525 526 // Auto-enable the GPU deblocking filter based on the KTX2 file's stored 527 // DeblockFilterID (KTX2File.getDeblockingFilterIndex()): the encoder sets it 528 // to 1 when the texture was compressed with deblocking-aware encoding, so the 529 // viewer should deblock by default. This mirrors the native C++ sample. The 530 // user can still toggle it (key 1 / the Deblock button); we capture the auto 531 // value as the Space-reset default. 532 g.const0[0] = (deblockId === 1) ? 1.0 : 0.0; 533 initConst0 = g.const0.slice(); 534 535 freeMesh(quad); 536 quad = createQuad(w / h); 537 applyFilterMode(); 538 539 hideError(); 540 console.log('Loaded ' + url + ': ' + w + 'x' + h + ' levels=' + levels + 541 ' block=' + bw + 'x' + bh + ' src=' + texInfo.srcFmt + ' gpu=' + fmt.name +
542 ' deblockID=' + deblockId); 543 } finally { 544 // If we threw after creating the new texture but before publishing it (texture 545 // still points at the old one, or null), free the orphaned handle so failed 546 // loads don't leak a GL texture. 547 if (tex && texture !== tex) gl.deleteTexture(tex); 548 ktx2File.close(); 549 ktx2File.delete(); 550 } 551} 552 553function applyFilterMode() { 554 if (!texture) return; 555 gl.bindTexture(gl.TEXTURE_2D, texture); 556 var mips = texInfo.levels > 1; 557 if (g.filterMode === 1) { // bilinear 558 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, mips ? gl.LINEAR_MIPMAP_NEAREST : gl.LINEAR); 559 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); 560 } else if (g.filterMode === 2) { // trilinear 561 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, mips ? gl.LINEAR_MIPMAP_LINEAR : gl.LINEAR); 562 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); 563 } else { // point 564 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, mips ? gl.NEAREST_MIPMAP_NEAREST : gl.NEAREST); 565 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); 566 } 567} 568 569// --------------------------------------------------------------------------- 570// Rendering. 571// --------------------------------------------------------------------------- 572function resizeCanvas() { 573 // Cap DPR: on a DPR-3 phone an uncapped backing store can be ~25M pixels, which 574 // (with the 5-tap deblock shader) risks context loss / thermal throttling. 575 var dpr = Math.min(window.devicePixelRatio || 1, 2); 576 var w = Math.max(1, Math.floor(canvas.clientWidth * dpr)); 577 var h = Math.max(1, Math.floor(canvas.clientHeight * dpr)); 578 if (canvas.width !== w || canvas.height !== h) { 579 canvas.width = w; 580 canvas.height = h; 581 } 582} 583 584function draw() { 585 resizeCanvas(); 586 gl.viewport(0, 0, canvas.width, canvas.height); 587 gl.clearColor(0.2, 0.2, 0.2, 1.0); 588 gl.enable(gl.DEPTH_TEST); 589 gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); 590 591 if (!texture || !prog) return; 592 593 gl.useProgram(prog.program); 594 595 var aspect = canvas.width / canvas.height; 596 mat4Perspective(FOV_DEGREES, aspect, Z_NEAR, Z_FAR, _proj); 597 mat4Translate(g.x, g.y, g.z, _trans); 598 mat4RotY(g.yaw, _rotY); 599 mat4RotX(g.pitch, _rotX); 600 mat4Multiply(_rotY, _rotX, _rot); // model = T * (RotY * RotX) 601 mat4Multiply(_trans, _rot, _model); 602 mat4Multiply(_proj, _model, _mvp); // mvp = proj * model 603 604 var u = prog.uniforms; 605 if (u.mvp) gl.uniformMatrix4fv(u.mvp, false, _mvp); 606 if (u.tex) gl.uniform1i(u.tex, 0); 607 if (u.texSize) gl.uniform4f(u.texSize, texInfo.w, texInfo.h, texInfo.blockW, texInfo.blockH); 608 if (u.maxLod) gl.uniform1f(u.maxLod, texInfo.levels - 1); 609 if (u.const0) gl.uniform4f(u.const0, g.const0[0], g.const0[1], g.const0[2], g.const0[3]); 610 if (u.const1) gl.uniform4f(u.const1, g.const1[0], g.const1[1], g.const1[2], g.const1[3]); 611 612 gl.activeTexture(gl.TEXTURE0); 613 gl.bindTexture(gl.TEXTURE_2D, texture); 614 615 var mesh = g.cube ? cube : quad; 616 gl.bindVertexArray(mesh.vao); 617 gl.drawElements(gl.TRIANGLES, mesh.count, gl.UNSIGNED_SHORT, 0); 618 gl.bindVertexArray(null); 619} 620 621function filterName(m) { return m === 0 ? 'POINT' : (m === 2 ? 'TRILINEAR' : 'BILINEAR'); } 622 623function updateOverlay() { 624 if (loading) { 625 document.getElementById('info').textContent = 'Loading ' + currentUrl + ' â¦'; 626 return; 627 } 628 var c0 = g.const0.map(function (v) { return v ? 1 : 0; }).join(''); 629 var c1 = g.const1.map(function (v) { return v ? 1 : 0; }).join(''); 630 var l0 = 'Res: ' + texInfo.w + 'x' + texInfo.h + ' Mips: ' + texInfo.levels + 631 ' Block: ' + texInfo.blockW + 'x' + texInfo.blockH + 632 ' DeblockID: ' + texInfo.deblockId + 633 ' Src: ' + texInfo.srcFmt + ' GPU: ' + texInfo.gpuFmt; 634 var l1 = 'Mode: ' + (g.cube ? 'CUBE' : 'QUAD') + ' Filter: ' + filterName(g.filterMode) + 635 ' Deblock(1) / Edges(2): [' + c0 + '][' + c1 + ']'; 636 var l2 = 'X:' + g.x.toFixed(1) + ' Y:' + g.y.toFixed(1) + ' Z:' + g.z.toFixed(1) + 637 ' Yaw:' + g.yaw.toFixed(1) + ' Pitch:' + g.pitch.toFixed(1); 638 document.getElementById('info').textContent = l0 + '\n' + l1 + '\n' + l2; 639} 640 641// --------------------------------------------------------------------------- 642// Input. 643// --------------------------------------------------------------------------- 644// Discrete actions, shared by the keyboard and the on-screen buttons. 645function toggleCube() { g.cube = !g.cube; } 646function setFilter(m) { g.filterMode = m; applyFilterMode(); } 647function cycleFilter() { setFilter((g.filterMode + 1) % 3); } // point -> bilinear -> trilinear 648function toggleDeblock() { g.const0[0] = 1 - g.const0[0]; } 649function toggleEdges() { g.const0[1] = 1 - g.const0[1]; } 650function resetView() { 651 g.x = 0; g.y = 0; g.z = -0.4; g.yaw = 0; g.pitch = 0; 652 g.const0 = initConst0.slice(); 653 g.const1 = [0, 0, 0, 0]; 654} 655function clampZ() { 656 if (g.z < Z_MAX) g.z = Z_MAX; 657 if (g.z > Z_MIN) g.z = Z_MIN; 658} 659 660function onKeyDown(e) { 661 // Stop the page from scrolling on arrows/space. 662 if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Space'].indexOf(e.code) >= 0) 663 e.preventDefault(); 664 665 heldKeys[e.code] = true; 666 667 if (e.repeat) return; // discrete actions fire once per physical press 668 switch (e.code) { 669 case 'KeyC': toggleCube(); break; 670 case 'KeyB': setFilter(1); break; 671 case 'KeyT': setFilter(2); break; 672 case 'KeyP': setFilter(0); break; 673 case 'Digit1': toggleDeblock(); break; 674 case 'Digit2': toggleEdges(); break; 675 case 'Digit3': g.const0[2] = 1 - g.const0[2]; break; 676 case 'Digit4': g.const0[3] = 1 - g.const0[3]; break; 677 case 'Digit5': g.const1[0] = 1 - g.const1[0]; break; 678 case 'Digit6': g.const1[1] = 1 - g.const1[1]; break; 679 case 'Digit7': g.const1[2] = 1 - g.const1[2]; break; 680 case 'Digit8': g.const1[3] = 1 - g.const1[3]; break; 681 case 'Space': resetView(); break; 682 } 683} 684function onKeyUp(e) { heldKeys[e.code] = false; } 685 686// --------------------------------------------------------------------------- 687// Pointer / touch / wheel controls (mouse + touchscreen, no keyboard needed): 688// one-finger / left-drag -> rotate (yaw + pitch) 689// two-finger pinch -> zoom; two-finger drag -> pan 690// mouse wheel -> zoom 691// --------------------------------------------------------------------------- 692var ROT_DRAG_MOUSE = 0.5; // degrees of rotation per pixel (mouse) 693var ROT_DRAG_TOUCH = 0.25; // gentler for touch â a finger flick covers more pixels 694var PINCH_ZOOM = 0.012; // z units per pixel of pinch spread 695var PAN_SPEED = 0.004; // world units per pixel of two-finger drag 696var WHEEL_ZOOM = 0.0025; 697 698var pointers = {}; // active pointerId -> {x, y} 699var pinchDist = 0; 700var panMid = null; 701 702function pointerCount() { return Object.keys(pointers).length; } 703 704function onPointerDown(e) { 705 pointers[e.pointerId] = { x: e.clientX, y: e.clientY }; 706 // Any change in finger count re-seeds the pinch/pan baseline on the next move, so 707 // adding/removing a finger (1<->2<->3) never produces a one-frame zoom/pan jump. 708 pinchDist = 0; panMid = null; 709 // Capture so we still get pointermove/up even if the finger leaves the canvas; 710 // otherwise a missed pointerup leaves a stale entry that breaks rotate forever. 711 try { canvas.setPointerCapture(e.pointerId); } catch (err) { /* not fatal */ } 712 e.preventDefault(); 713} 714function onPointerMove(e) { 715 var p = pointers[e.pointerId]; 716 if (!p) return; 717 var nx = e.clientX, ny = e.clientY; 718 var n = pointerCount(); 719 720 if (n === 1) { 721 var rot = (e.pointerType === 'touch') ? ROT_DRAG_TOUCH : ROT_DRAG_MOUSE; 722 g.yaw += (nx - p.x) * rot; 723 g.pitch += (ny - p.y) * rot; 724 } 725 p.x = nx; p.y = ny; 726 727 if (n >= 2) { 728 var ids = Object.keys(pointers); 729 var a = pointers[ids[0]], b = pointers[ids[1]]; 730 var dist = Math.hypot(a.x - b.x, a.y - b.y); 731 var midx = (a.x + b.x) / 2, midy = (a.y + b.y) / 2; 732 if (pinchDist > 0) { 733 g.z += (dist - pinchDist) * PINCH_ZOOM; // spread fingers -> move closer 734 if (panMid) { g.x += (midx - panMid.x) * PAN_SPEED; g.y -= (midy - panMid.y) * PAN_SPEED; } 735 clampZ(); 736 } 737 pinchDist = dist; 738 panMid = { x: midx, y: midy }; 739 } 740 e.preventDefault(); 741} 742function onPointerUp(e) { 743 try { canvas.releasePointerCapture(e.pointerId); } catch (err) { /* ignore */ } 744 delete pointers[e.pointerId]; 745 pinchDist = 0; panMid = null; // re-seed pinch/pan baseline after any finger lifts 746} 747function onWheel(e) { 748 g.z -= e.deltaY * WHEEL_ZOOM; // wheel up -> closer 749 clampZ(); 750 e.preventDefault(); 751} 752 753// Keep the on-screen buttons' labels/active state in sync with the model state 754// (so keyboard and touch stay consistent). 755function syncButtons() {
756 // Compact labels so the 5 buttons fit one horizontal row on a phone. Deblock/Edges 757 // on-state is shown by the green .active highlight (and also in the top-left overlay). 758 var b; 759 if ((b = document.getElementById('btnMode'))) b.textContent = g.cube ? 'Cube' : 'Quad'; 760 if ((b = document.getElementById('btnDeblock'))) { 761 b.textContent = 'Deblock'; 762 b.classList.toggle('active', !!g.const0[0]); 763 } 764 if ((b = document.getElementById('btnEdges'))) { 765 b.textContent = 'Edges'; 766 b.classList.toggle('active', !!g.const0[1]); 767 } 768 if ((b = document.getElementById('btnFilter'))) b.textContent = filterName(g.filterMode); 769} 770 771function processHeldKeys(dt) { 772 if (heldKeys['ShiftLeft'] || heldKeys['ShiftRight']) dt *= 1.0 / 3.0; 773 if (heldKeys['KeyW']) g.z += Z_SPEED * dt; 774 if (heldKeys['KeyS']) g.z -= Z_SPEED * dt; 775 if (heldKeys['ArrowLeft']) g.x += XY_SPEED * dt; 776 if (heldKeys['ArrowRight']) g.x -= XY_SPEED * dt; 777 if (heldKeys['ArrowUp']) g.y += XY_SPEED * dt; 778 if (heldKeys['ArrowDown']) g.y -= XY_SPEED * dt; 779 if (heldKeys['KeyA']) g.yaw += ROT_SPEED * dt; 780 if (heldKeys['KeyD']) g.yaw -= ROT_SPEED * dt; 781 if (heldKeys['KeyQ']) g.pitch += ROT_SPEED * dt; 782 if (heldKeys['KeyE']) g.pitch -= ROT_SPEED * dt; 783 if (g.z < Z_MAX) g.z = Z_MAX; 784 if (g.z > Z_MIN) g.z = Z_MIN; 785} 786 787// --------------------------------------------------------------------------- 788// Error overlay. 789// --------------------------------------------------------------------------- 790function showError(msg) { 791 var e = document.getElementById('error'); 792 e.textContent = msg; 793 e.style.display = 'block'; 794} 795function hideError() { document.getElementById('error').style.display = 'none'; } 796 797// --------------------------------------------------------------------------- 798// Main loop + bootstrap. 799// --------------------------------------------------------------------------- 800function frame(now) { 801 var dt = (now - lastTime) / 1000.0; 802 lastTime = now; 803 if (dt > 0.1) dt = 0.1; // cap after a stall to avoid a teleport 804 processHeldKeys(dt); 805 draw(); 806 updateOverlay(); 807 syncButtons(); 808 rafId = requestAnimationFrame(frame); 809} 810 811// (Re)create all GL objects. Called at startup and again after a context restore, 812// where every prior GL handle has been invalidated. 813function initGL() { 814 detectCaps(); 815 prog = buildProgram(VERTEX_SHADER_SRC, FRAGMENT_SHADER_SRC); 816 cube = createCube(); 817} 818 819function start() { 820 canvas = document.getElementById('canvas'); 821 // Drop MSAA on high-DPR screens (it adds little at DPR>=2 and doubles bandwidth on 822 // tile-based mobile GPUs); the DPR cap in resizeCanvas keeps the buffer reasonable. 823 var wantAA = (window.devicePixelRatio || 1) < 2; 824 gl = canvas.getContext('webgl2', { alpha: false, depth: true, antialias: wantAA }); 825 if (!gl) { 826 showError('WebGL 2 is required for this sample (#version 300 es shaders), but ' + 827 'this browser/device did not provide a WebGL 2 context.'); 828 return; 829 } 830 831 initGL(); 832 833 // Mobile browsers drop the GL context when backgrounded or under memory pressure. 834 // Without preventDefault() the context is never restored (permanently black canvas); 835 // on restore every GL object is invalid and must be rebuilt. 836 canvas.addEventListener('webglcontextlost', function (e) { 837 e.preventDefault(); 838 if (rafId) { cancelAnimationFrame(rafId); rafId = 0; } 839 loading = false; // don't let a "Loadingâ¦" overlay stay wedged if loss hit mid-load 840 }, false); 841 canvas.addEventListener('webglcontextrestored', function () { 842 // A throw in here (e.g. shader relink fails under the same memory pressure that 843 // caused the loss) would otherwise never restart the loop -> permanent freeze 844 // with no feedback. Catch it and tell the user. 845 try { 846 texture = null; quad = null; // old handles are dead 847 initGL(); 848 loadKtx2(currentUrl); 849 lastTime = performance.now(); 850 rafId = requestAnimationFrame(frame); 851 } catch (err) { 852 showError('The GPU context was lost and could not be rebuilt â please reload the page.'); 853 console.error('context restore failed:', err); 854 } 855 }, false); 856 857 // Show touch-only help on coarse-pointer devices (the keyboard line is noise on 858 // a phone/tablet); show the full keyboard reference on mouse/desktop. 859 var coarse = window.matchMedia && window.matchMedia('(pointer: coarse)').matches; 860 document.getElementById('help').textContent = coarse 861 ? 'Drag: rotate Two-finger pinch: zoom / drag: pan Buttons below' 862 : 'Drag: rotate Two-finger pinch: zoom / drag: pan Wheel: zoom\n' + 863 'Keys â Move: Arrows/W,S Rotate: A,D / Q,E Shift: slow C B T P 1 2 Space: reset'; 864 865 document.getElementById('asset').addEventListener('change', function () { 866 loadKtx2(this.value); 867 }); 868 869 // On-screen buttons (so the sample is fully usable on phones/tablets). 870 var bind = function (id, fn) { 871 var el = document.getElementById(id); 872 if (el) el.addEventListener('click', fn); 873 }; 874 bind('btnMode', toggleCube); 875 bind('btnDeblock', toggleDeblock); 876 bind('btnEdges', toggleEdges); 877 bind('btnFilter', cycleFilter); 878 bind('btnReset', resetView); 879 880 window.addEventListener('keydown', onKeyDown); 881 window.addEventListener('keyup', onKeyUp); 882 883 // Pointer + wheel controls on the canvas (mouse and touch). 884 canvas.addEventListener('pointerdown', onPointerDown); 885 canvas.addEventListener('pointermove', onPointerMove); 886 canvas.addEventListener('pointerup', onPointerUp); 887 canvas.addEventListener('pointercancel', onPointerUp); 888 // No 'pointerleave' handler: with setPointerCapture the canvas keeps receiving 889 // events when the finger/cursor leaves its bounds, so deleting the pointer on 890 // leave would wrongly abort an in-progress drag. 891 canvas.addEventListener('wheel', onWheel, { passive: false }); 892 // Suppress the long-press/right-click context menu so it can't interrupt a drag 893 // (Android long-press, desktop right-drag). 894 canvas.addEventListener('contextmenu', function (e) { e.preventDefault(); }); 895 896 loadKtx2(DEFAULT_ASSET); 897 898 lastTime = performance.now(); 899 rafId = requestAnimationFrame(frame); 900} 901 902// Boot the Basis Universal WASM transcoder, then start (the BASIS() factory and 903// initializeBasis() are the shared bootstrap used by the other webgl/ samples). 904if (typeof BASIS === 'undefined') { 905 // The transcoder <script> failed to load (blocked, 404, wrong path). Without 906 // this guard the next line throws a ReferenceError before we can surface it. 907 showError('Could not load the Basis transcoder script ' + 908 '(../transcoder/build/basis_transcoder.js). Serve this page over HTTP from the ' +
909 'webgl/ directory so the relative path resolves.'); 910} else { 911 BASIS({ 912 onRuntimeInitialized: function () { } 913 }).then(function (module) { 914 Module = module; 915 Module.initializeBasis(); 916 start(); 917 }).catch(function (e) { 918 showError('Failed to initialize the Basis transcoder WASM module: ' + (e.message || e)); 919 console.error(e); 920 }); 921}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.