adding new sample
diff --git a/webgl/shader_deblocking/assets/frymire.ktx2 b/webgl/shader_deblocking/assets/frymire.ktx2
new file mode 100644
index 0000000..06e1700
--- /dev/null
+++ b/webgl/shader_deblocking/assets/frymire.ktx2
Binary files differ
diff --git a/webgl/shader_deblocking/assets/frymire2.ktx2 b/webgl/shader_deblocking/assets/frymire2.ktx2
new file mode 100644
index 0000000..2ac8123
--- /dev/null
+++ b/webgl/shader_deblocking/assets/frymire2.ktx2
Binary files differ
diff --git a/webgl/shader_deblocking/assets/kodim01.ktx2 b/webgl/shader_deblocking/assets/kodim01.ktx2
new file mode 100644
index 0000000..f3c4362
--- /dev/null
+++ b/webgl/shader_deblocking/assets/kodim01.ktx2
Binary files differ
diff --git a/webgl/shader_deblocking/assets/kodim03.ktx2 b/webgl/shader_deblocking/assets/kodim03.ktx2
new file mode 100644
index 0000000..c8eb589
--- /dev/null
+++ b/webgl/shader_deblocking/assets/kodim03.ktx2
Binary files differ
diff --git a/webgl/shader_deblocking/assets/kodim23.ktx2 b/webgl/shader_deblocking/assets/kodim23.ktx2
new file mode 100644
index 0000000..1a0a80f
--- /dev/null
+++ b/webgl/shader_deblocking/assets/kodim23.ktx2
Binary files differ
diff --git a/webgl/shader_deblocking/assets/tng.ktx2 b/webgl/shader_deblocking/assets/tng.ktx2
new file mode 100644
index 0000000..ab133d5
--- /dev/null
+++ b/webgl/shader_deblocking/assets/tng.ktx2
Binary files differ
diff --git a/webgl/shader_deblocking/assets/tng2.ktx2 b/webgl/shader_deblocking/assets/tng2.ktx2
new file mode 100644
index 0000000..3e1812e
--- /dev/null
+++ b/webgl/shader_deblocking/assets/tng2.ktx2
Binary files differ
diff --git a/webgl/shader_deblocking/assets/tng3.ktx2 b/webgl/shader_deblocking/assets/tng3.ktx2
new file mode 100644
index 0000000..5f3d912
--- /dev/null
+++ b/webgl/shader_deblocking/assets/tng3.ktx2
Binary files differ
diff --git a/webgl/shader_deblocking/index.html b/webgl/shader_deblocking/index.html
new file mode 100644
index 0000000..a1f03a7
--- /dev/null
+++ b/webgl/shader_deblocking/index.html
@@ -0,0 +1,140 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+   <meta charset="utf-8" />
+   <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
+   <title>Shader Deblocking</title>
+   <style>
+      html, body {
+         margin: 0;
+         padding: 0;
+         height: 100%;
+         background: #202020;
+         color: #e0e0e0;
+         font-family: Consolas, "Courier New", monospace;
+         overflow: hidden;
+         overscroll-behavior: none; /* stop Android pull-to-refresh / iOS rubber-banding */
+      }
+      #canvas {
+         display: block;
+         width: 100vw;
+         height: 100vh;   /* fallback for older browsers */
+         height: 100dvh;  /* dynamic viewport height: correct under the mobile URL bar */
+         touch-action: none; /* we handle drag/pinch ourselves; stop browser pan/zoom */
+      }
+      /* Text overlays sit on top of the GL canvas. The native sample drew an
+         8x8 bitmap font into the framebuffer; in the browser plain HTML/CSS is
+         simpler and crisper. */
+      .overlay {
+         position: fixed;
+         left: calc(8px + env(safe-area-inset-left));
+         pointer-events: none;
+         white-space: pre;
+         font-size: 13px;
+         line-height: 1.35;
+         text-shadow: 0 0 3px #000, 0 0 3px #000;
+      }
+      /* #info top-left; #help stacked just below it (kept away from the bottom
+         button bar, which can wrap to multiple rows on a narrow phone). */
+      #info { top: calc(8px + env(safe-area-inset-top)); }
+      #help { top: calc(70px + env(safe-area-inset-top)); color: #b0b0b0; font-size: 12px; }
+      /* On-screen control bar — keeps the sample fully usable on touch devices. */
+      #buttons {
+         position: fixed;
+         left: 50%;
+         bottom: calc(10px + env(safe-area-inset-bottom));
+         transform: translateX(-50%);
+         display: flex;
+         flex-wrap: nowrap;           /* single horizontal row along the bottom */
+         gap: 6px;
+         justify-content: center;
+         max-width: calc(100vw - 12px);
+         overflow-x: auto;            /* scroll rather than stack if a screen is too narrow */
+         pointer-events: auto;
+      }
+      #buttons button {
+         background: #303030;
+         color: #e0e0e0;
+         border: 1px solid #555;
+         font-family: inherit;
+         font-size: 13px;
+         padding: 10px 8px;
+         min-height: 44px;            /* iOS HIG minimum tap target */
+         white-space: nowrap;         /* keep each label on one line */
+         flex: 0 0 auto;
+         border-radius: 5px;
+         cursor: pointer;
+         touch-action: manipulation;  /* kill the 300ms double-tap delay/zoom */
+      }
+      /* Guard :hover so it doesn't "stick" after a tap on touch devices. */
+      @media (hover: hover) { #buttons button:hover { background: #3a3a3a; } }
+      #buttons button:active { background: #454545; }  /* immediate touch feedback */
+      #buttons button.active { background: #2a5d2a; border-color: #3a8a3a; }
+      /* Pulldown sits BELOW the debug text (#info 3 lines + #help), left-aligned, so
+         it never overlaps/obscures the overlay text on a narrow phone screen. */
+      #controls {
+         position: fixed;
+         top: calc(112px + env(safe-area-inset-top));
+         left: calc(8px + env(safe-area-inset-left));
+         pointer-events: auto;
+         font-size: 13px;
+      }
+      #controls select {
+         background: #303030;
+         color: #e0e0e0;
+         border: 1px solid #555;
+         font-family: inherit;
+         font-size: 16px;     /* >=16px stops iOS auto-zoom on focus */
+         padding: 8px 10px;
+         min-height: 44px;
+         touch-action: manipulation;
+      }
+      #error {
+         position: fixed;
+         left: 50%;
+         top: 50%;
+         transform: translate(-50%, -50%);
+         color: #ff8080;
+         font-size: 15px;
+         max-width: 80vw;
+         text-align: center;
+         display: none;
+      }
+   </style>
+</head>
+<body>
+   <canvas id="canvas"></canvas>
+
+   <div id="info" class="overlay">Loading…</div>
+   <div id="help" class="overlay"></div>
+
+   <div id="buttons">
+      <button id="btnMode">Quad</button>
+      <button id="btnDeblock">Deblock</button>
+      <button id="btnEdges">Edges</button>
+      <button id="btnFilter">Trilinear</button>
+      <button id="btnReset">Reset</button>
+   </div>
+
+   <div id="controls">
+      <label for="asset">Texture: </label>
+      <select id="asset">
+         <option value="assets/kodim01.ktx2">kodim01.ktx2</option>
+         <option value="assets/kodim03.ktx2">kodim03.ktx2</option>
+         <option value="assets/kodim23.ktx2" selected>kodim23.ktx2</option>
+         <option value="assets/tng.ktx2">tng.ktx2 (8x8, mipped)</option>
+         <option value="assets/tng2.ktx2">tng2.ktx2 (8x8, mipped, 1000h)</option>
+         <option value="assets/tng3.ktx2">tng3.ktx2 (12x12, mipped)</option>
+         <option value="assets/frymire.ktx2">frymire.ktx2</option>
+         <option value="assets/frymire2.ktx2">frymire2.ktx2</option>
+      </select>
+   </div>
+
+   <div id="error"></div>
+
+   <!-- Shared Basis Universal transcoder (transcode-only WASM build), same one
+        used by the other webgl/ samples. main.js boots it via BASIS(). -->
+   <script src="../transcoder/build/basis_transcoder.js"></script>
+   <script src="main.js"></script>
+</body>
+</html>
diff --git a/webgl/shader_deblocking/main.js b/webgl/shader_deblocking/main.js
new file mode 100644
index 0000000..01dd5e1
--- /dev/null
+++ b/webgl/shader_deblocking/main.js
@@ -0,0 +1,936 @@
+// Shader Deblocking — WebGL 2 browser port of the native shader_deblocking_glfw sample.
+// Copyright (C) 2026 Binomial LLC.  LICENSE: Apache 2.0
+//
+// Loads a Basis Universal .KTX2 (via the WASM transcoder), transcodes every mip
+// level to a GPU format (ASTC -> BC7 -> ETC1/ETC2, falling back to uncompressed
+// RGBA32), and renders it on a textured quad or cube with the mipmap-aware
+// deblocking pixel shader (GLSL ES 3.00 / #version 300 es).
+//
+// Controls: arrows move, W/S zoom, A/D yaw, Q/E pitch, C cube/quad, B/T/P filter,
+//           1 deblock on/off, 2 edge visualization, 3-8 spare consts, Space reset.
+
+'use strict';
+
+// ---------------------------------------------------------------------------
+// Tunables (mirror the native sample's constants).
+// ---------------------------------------------------------------------------
+var FOV_DEGREES = 90.0;
+var Z_NEAR = 0.001, Z_FAR = 100.0;
+var Z_MIN = 0.40, Z_MAX = -50.0;     // camera-z clamp range (z is negative going away)
+var Z_SPEED = 1.0, XY_SPEED = 0.75, ROT_SPEED = 90.0;
+var DEFAULT_ASSET = 'assets/kodim23.ktx2';
+
+// ---------------------------------------------------------------------------
+// Shaders — the native bin/shader.glsl ported to #version 300 es. The deblock
+// body is byte-identical to the desktop version; only the version line and the
+// fragment precision qualifiers differ (dFdx/dFdy are core in GLSL ES 3.00).
+// NOTE: "#version 300 es" MUST be the very first characters of the source.
+// ---------------------------------------------------------------------------
+var VERTEX_SHADER_SRC = `#version 300 es
+layout(location = 0) in vec3 aPos;
+layout(location = 1) in vec2 aUV;
+
+uniform mat4 mvp;
+
+out vec2 vUV;
+
+void main() {
+    vUV = aUV;
+    gl_Position = mvp * vec4(aPos, 1.0);
+}
+`;
+
+var FRAGMENT_SHADER_SRC = `#version 300 es
+precision highp float;
+precision highp sampler2D;
+
+uniform sampler2D tex;
+uniform vec4 texSize;  // xy = base mip (mip0) dims, zw = deblock block size in texels
+uniform float maxLod;  // number_of_mip_levels - 1
+
+uniform vec4 const0;   // x = deblock on, y = edge-weight viz, z/w spare
+uniform vec4 const1;   // spare user constants
+
+in vec2 vUV;
+out vec4 fragColor;
+
+void main()
+{
+    vec2 texDim = vec2(texSize.x, texSize.y);
+    vec2 blockSize = vec2(texSize.z, texSize.w);
+
+    // Recover the effective mip level from screen-space UV derivatives, so the
+    // deblock lattice tracks whichever mip the hardware is actually sampling.
+    vec2 du = dFdx(vUV);
+    vec2 dv = dFdy(vUV);
+    float rho = max(length(du * texSize.xy), length(dv * texSize.xy));
+    float lod = clamp(log2(max(rho, 1e-8)), 0.0, maxLod);
+    float mipScale = exp2(floor(lod + 0.5)); // snap to dominant mip (1=mip0, 2=mip1, ...)
+
+    vec2 texelStep = mipScale / texDim;          // one texel in effective mip space
+    vec2 texelPos  = (vUV * texDim) / mipScale;  // texel coord in effective mip space
+    vec2 blockPos  = mod(texelPos, blockSize);   // offset within the current block
+
+    vec3 color = texture(tex, vUV).rgb;
+
+    // Keep these fetches outside non-uniform control flow: texture() uses implicit
+    // derivatives for LOD, which are undefined when neighboring fragments take
+    // different branches. Use base sampling so edgeWeight can safely go to zero.
+    vec3 l1 = texture(tex, vUV - vec2(texelStep.x, 0.0)).rgb;
+    vec3 r1 = texture(tex, vUV + vec2(texelStep.x, 0.0)).rgb;
+    vec3 u1 = texture(tex, vUV - vec2(0.0, texelStep.y)).rgb;
+    vec3 d1 = texture(tex, vUV + vec2(0.0, texelStep.y)).rgb;
+
+    if (const0.x > 0.5)
+    {
+        const float falloff = 1.5;
+
+        float leftProx   = 1.0 - clamp(blockPos.x / falloff, 0.0, 1.0);
+        float rightProx  = 1.0 - clamp((blockSize.x - blockPos.x) / falloff, 0.0, 1.0);
+        float topProx    = 1.0 - clamp(blockPos.y / falloff, 0.0, 1.0);
+        float bottomProx = 1.0 - clamp((blockSize.y - blockPos.y) / falloff, 0.0, 1.0);
+
+        float horizWeight = max(leftProx, rightProx);
+        float vertWeight  = max(topProx, bottomProx);
+        float edgeWeight  = max(horizWeight, vertWeight);
+
+        if (edgeWeight > 0.0)
+        {
+            vec3 c0 = color;
+            vec3 filteredH = (l1 + c0 + r1) * (1.0 / 3.0);
+            vec3 filteredV = (u1 + c0 + d1) * (1.0 / 3.0);
+
+            float strengthH = horizWeight;
+            float strengthV = vertWeight;
+
+            vec3 horizColor = mix(c0, filteredH, strengthH);
+            vec3 vertColor  = mix(c0, filteredV, strengthV);
+
+            float totalW = strengthH + strengthV;
+            if (totalW > 0.0)
+                color = (horizColor * strengthH + vertColor * strengthV) / totalW;
+        }
+
+        // Block-edge weight visualization (key 2).
+        if (const0.y > 0.5)
+            color = vec3(edgeWeight, edgeWeight, edgeWeight);
+    }
+
+    fragColor = vec4(color, 1.0);
+}
+`;
+
+// ---------------------------------------------------------------------------
+// GPU format constants + transcoder_texture_format enum (subset; LDR only).
+// ---------------------------------------------------------------------------
+// WEBGL_compressed_texture_astc internal formats keyed by literal (w,h). The GL
+// enum order is W-major and is NOT the same as Basis's area-ordered transcoder
+// enum (note 8x8 sits out of numeric order), so we ALWAYS map by (w,h), never by
+// an ordinal.
+//
+// IMPORTANT: we deliberately use the LINEAR (non-sRGB) ASTC enums (0x93B0..), NOT
+// the sRGB variants (base + 0x20). This shader writes the sampled texel straight
+// to the canvas with no linear->sRGB encode, so the stored (already-sRGB-encoded)
+// bytes must be sampled verbatim. An sRGB internal format would make the hardware
+// decode sRGB->linear on sample, and the un-encoded linear output then displays
+// far too dark. BC7 (UNORM) and the native C++ sample behave the same way.
+var ASTC_GL = {
+   '4,4': 0x93B0, '5,4': 0x93B1, '5,5': 0x93B2, '6,5': 0x93B3, '6,6': 0x93B4,
+   '8,5': 0x93B5, '8,6': 0x93B6, '8,8': 0x93B7, '10,5': 0x93B8, '10,6': 0x93B9,
+   '10,8': 0x93BA, '10,10': 0x93BB, '12,10': 0x93BC, '12,12': 0x93BD
+};
+// Matching transcoder_texture_format values (Module.transcoder_texture_format).
+var ASTC_TF = {
+   '4,4': 10, '5,4': 28, '5,5': 29, '6,5': 30, '6,6': 31, '8,5': 32, '8,6': 33,
+   '10,5': 34, '10,6': 35, '8,8': 36, '10,8': 37, '10,10': 38, '12,10': 39, '12,12': 40
+};
+var TF_BC7 = 6, TF_ETC1 = 0, TF_RGBA32 = 13;
+var GL_COMPRESSED_RGBA_BPTC_UNORM = 0x8E8C;
+var GL_COMPRESSED_RGB_ETC1_WEBGL = 0x8D64;  // ETC1 is RGB-only (alpha dropped); fine for these LDR photos
+var GL_COMPRESSED_RGB8_ETC2 = 0x9274;       // ETC2 RGB (superset of ETC1) — used when only the ETC2 ext is present
+                                            // (linear, non-sRGB — see the ASTC note above for why)
+
+function astcGLName(fmt) {
+   for (var k in ASTC_GL) { if (ASTC_GL[k] === fmt) return 'ASTC_' + k.replace(',', 'x'); }
+   if (ASTC_GL['4,4'] + 0x20 <= fmt && fmt <= ASTC_GL['12,12'] + 0x20) {
+      for (var k2 in ASTC_GL) { if (ASTC_GL[k2] + 0x20 === fmt) return 'SRGB_ASTC_' + k2.replace(',', 'x'); }
+   }
+   return '0x' + fmt.toString(16);
+}
+
+// ---------------------------------------------------------------------------
+// Column-major 4x4 matrices (WebGL uniformMatrix4fv requires transpose=false,
+// so we build column-major directly — the native sample was row-major + GL_TRUE).
+// ---------------------------------------------------------------------------
+// All matrix builders write into a caller-provided Float32Array `o` (and return
+// it), so draw() can reuse module-scope scratch buffers instead of allocating a
+// fresh matrix every frame (avoids ~360 allocs/sec of GC churn on mobile).
+function mat4Identity(o) {
+   o[0] = 1; o[1] = 0; o[2] = 0; o[3] = 0;
+   o[4] = 0; o[5] = 1; o[6] = 0; o[7] = 0;
+   o[8] = 0; o[9] = 0; o[10] = 1; o[11] = 0;
+   o[12] = 0; o[13] = 0; o[14] = 0; o[15] = 1;
+   return o;
+}
+function mat4Multiply(a, b, o) { // o = a*b; o must not alias a or b
+   for (var c = 0; c < 4; c++)
+      for (var r = 0; r < 4; r++) {
+         var s = 0;
+         for (var k = 0; k < 4; k++) s += a[k * 4 + r] * b[c * 4 + k];
+         o[c * 4 + r] = s;
+      }
+   return o;
+}
+function mat4Perspective(fovDeg, aspect, near, far, o) {
+   var f = 1.0 / Math.tan((fovDeg * Math.PI / 180.0) / 2.0);
+   for (var i = 0; i < 16; i++) o[i] = 0;
+   o[0] = f / aspect;
+   o[5] = f;
+   o[10] = (far + near) / (near - far);
+   o[11] = -1.0;
+   o[14] = (2.0 * far * near) / (near - far);
+   return o;
+}
+function mat4Translate(x, y, z, o) {
+   mat4Identity(o); o[12] = x; o[13] = y; o[14] = z; return o;
+}
+function mat4RotX(deg, o) {
+   var r = deg * Math.PI / 180.0, c = Math.cos(r), s = Math.sin(r);
+   mat4Identity(o); o[5] = c; o[6] = s; o[9] = -s; o[10] = c; return o;
+}
+function mat4RotY(deg, o) {
+   var r = deg * Math.PI / 180.0, c = Math.cos(r), s = Math.sin(r);
+   mat4Identity(o); o[0] = c; o[2] = -s; o[8] = s; o[10] = c; return o;
+}
+
+// ---------------------------------------------------------------------------
+// Global state.
+// ---------------------------------------------------------------------------
+var gl = null;
+var canvas = null;
+var Module = null;
+
+var prog = null;          // { program, uniforms{}, attribs{} }
+var caps = { astc: false, bc7: false, etc1: false, etc2: false };
+var astcExt = null, bptcExt = null, etc1Ext = null, etcExt = null;
+
+var quad = null;          // { vao, count }
+var cube = null;          // { vao, count }
+var texture = null;
+
+var g = {
+   x: 0, y: 0, z: -0.4, yaw: 0, pitch: 0,
+   cube: false,
+   filterMode: 2,         // 0=point, 1=bilinear, 2=trilinear (native startup = trilinear)
+   const0: [0, 0, 0, 0],
+   const1: [0, 0, 0, 0]
+};
+var initConst0 = [0, 0, 0, 0];
+
+// Info about the currently loaded texture (for the overlay).
+var texInfo = {
+   w: 0, h: 0, levels: 1, blockW: 4, blockH: 4,
+   deblockId: 0, srcFmt: '-', gpuFmt: '-'
+};
+
+var heldKeys = {};
+var lastTime = 0;
+// Reused scratch matrices for draw() (see mat4* note) — avoids per-frame allocation.
+var _proj = new Float32Array(16), _trans = new Float32Array(16);
+var _rotY = new Float32Array(16), _rotX = new Float32Array(16), _rot = new Float32Array(16);
+var _model = new Float32Array(16), _mvp = new Float32Array(16);
+var rafId = 0;                    // requestAnimationFrame handle (so we can stop/restart on context loss)
+var currentUrl = DEFAULT_ASSET;  // last-requested texture URL (for context-restore reload)
+var loadGen = 0;                 // monotonic token: newest load wins, stale loads bail out
+var loading = false;             // drives the "Loading…" overlay during a transcode
+
+// ---------------------------------------------------------------------------
+// GL helpers.
+// ---------------------------------------------------------------------------
+function compileShader(src, type) {
+   var sh = gl.createShader(type);
+   gl.shaderSource(sh, src);
+   gl.compileShader(sh);
+   if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
+      var log = gl.getShaderInfoLog(sh);
+      console.error((type === gl.VERTEX_SHADER ? 'VERTEX' : 'FRAGMENT') + ' shader error:\n' + log);
+      gl.deleteShader(sh);
+      throw new Error('Shader compile failed');
+   }
+   return sh;
+}
+function buildProgram(vsSrc, fsSrc) {
+   var program = gl.createProgram();
+   gl.attachShader(program, compileShader(vsSrc, gl.VERTEX_SHADER));
+   gl.attachShader(program, compileShader(fsSrc, gl.FRAGMENT_SHADER));
+   gl.linkProgram(program);
+   if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
+      console.error('Program link failed:\n' + gl.getProgramInfoLog(program));
+      throw new Error('Program link failed');
+   }
+   var uniforms = {};
+   var nu = gl.getProgramParameter(program, gl.ACTIVE_UNIFORMS);
+   for (var i = 0; i < nu; i++) {
+      var ui = gl.getActiveUniform(program, i);
+      uniforms[ui.name] = gl.getUniformLocation(program, ui.name);
+   }
+   var attribs = {};
+   var na = gl.getProgramParameter(program, gl.ACTIVE_ATTRIBUTES);
+   for (var j = 0; j < na; j++) {
+      var ai = gl.getActiveAttrib(program, j);
+      attribs[ai.name] = gl.getAttribLocation(program, ai.name);
+   }
+   return { program: program, uniforms: uniforms, attribs: attribs };
+}
+
+// Interleaved [pos.xyz, uv.xy] mesh -> VAO. Attribute locations match the
+// shader's layout(location=...) (0 = pos, 1 = uv).
+function makeMesh(verts, indices) {
+   var vao = gl.createVertexArray();
+   gl.bindVertexArray(vao);
+
+   var vbo = gl.createBuffer();
+   gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
+   gl.bufferData(gl.ARRAY_BUFFER, verts, gl.STATIC_DRAW);
+
+   var ebo = gl.createBuffer();
+   gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ebo);
+   gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW);
+
+   var stride = 5 * 4;
+   gl.enableVertexAttribArray(0);
+   gl.vertexAttribPointer(0, 3, gl.FLOAT, false, stride, 0);
+   gl.enableVertexAttribArray(1);
+   gl.vertexAttribPointer(1, 2, gl.FLOAT, false, stride, 3 * 4);
+
+   gl.bindVertexArray(null);
+   // Keep the VBO/EBO handles: deleting the VAO does NOT free its buffer objects
+   // in WebGL, so freeMesh() must delete all three or they leak on every reload.
+   return { vao: vao, vbo: vbo, ebo: ebo, count: indices.length };
+}
+
+// Free a mesh's VAO and its owned VBO/EBO. Safe to call with null.
+function freeMesh(m) {
+   if (!m) return;
+   gl.deleteVertexArray(m.vao);
+   gl.deleteBuffer(m.vbo);
+   gl.deleteBuffer(m.ebo);
+}
+
+// Quad fitted to the texture aspect (UVs v-flipped: bottom row v=1, top v=0).
+function createQuad(aspect) {
+   var hw, hh;
+   if (aspect >= 1.0) { hw = 1.0; hh = 1.0 / aspect; } else { hw = aspect; hh = 1.0; }
+   var v = new Float32Array([
+      -hw, -hh, 0.0, 0.0, 1.0,
+      hw, -hh, 0.0, 1.0, 1.0,
+      hw, hh, 0.0, 1.0, 0.0,
+      -hw, hh, 0.0, 0.0, 0.0
+   ]);
+   var idx = new Uint16Array([0, 1, 2, 0, 2, 3]);
+   return makeMesh(v, idx);
+}
+
+// Unit cube (half-extent 0.5), same texture mapped 0..1 on all six faces.
+function createCube() {
+   var h = 0.5;
+   var v = new Float32Array([
+      // front (z=+h)
+      -h, -h, h, 0, 1, h, -h, h, 1, 1, h, h, h, 1, 0, -h, h, h, 0, 0,
+      // back (z=-h)
+      h, -h, -h, 0, 1, -h, -h, -h, 1, 1, -h, h, -h, 1, 0, h, h, -h, 0, 0,
+      // right (x=+h)
+      h, -h, h, 0, 1, h, -h, -h, 1, 1, h, h, -h, 1, 0, h, h, h, 0, 0,
+      // left (x=-h)
+      -h, -h, -h, 0, 1, -h, -h, h, 1, 1, -h, h, h, 1, 0, -h, h, -h, 0, 0,
+      // top (y=+h)
+      -h, h, h, 0, 1, h, h, h, 1, 1, h, h, -h, 1, 0, -h, h, -h, 0, 0,
+      // bottom (y=-h)
+      -h, -h, -h, 0, 1, h, -h, -h, 1, 1, h, -h, h, 1, 0, -h, -h, h, 0, 0
+   ]);
+   var idx = new Uint16Array(36);
+   for (var i = 0; i < 6; i++) {
+      var b = i * 4, o = i * 6;
+      idx[o] = b; idx[o + 1] = b + 1; idx[o + 2] = b + 2;
+      idx[o + 3] = b; idx[o + 4] = b + 2; idx[o + 5] = b + 3;
+   }
+   return makeMesh(v, idx);
+}
+
+// ---------------------------------------------------------------------------
+// GPU format support detection (WebGL 2: compressed formats are still
+// extensions; compressedTexImage2D + NPOT mip chains are core).
+// ---------------------------------------------------------------------------
+function detectCaps() {
+   astcExt = gl.getExtension('WEBGL_compressed_texture_astc');
+   bptcExt = gl.getExtension('EXT_texture_compression_bptc');
+   etc1Ext = gl.getExtension('WEBGL_compressed_texture_etc1');
+   etcExt  = gl.getExtension('WEBGL_compressed_texture_etc');   // ETC2/EAC (GLES3-core; common on Android)
+   caps.astc = !!astcExt;
+   caps.bc7 = !!bptcExt;
+   caps.etc1 = !!etc1Ext;
+   caps.etc2 = !!etcExt;
+   console.log('GPU compressed support: ASTC=' + (caps.astc ? 1 : 0) +
+      ' BC7=' + (caps.bc7 ? 1 : 0) + ' ETC1=' + (caps.etc1 ? 1 : 0) + ' ETC2=' + (caps.etc2 ? 1 : 0));
+}
+
+// Pick a GPU storage format via the ASTC(same block size) -> BC7 -> ETC1/ETC2 -> RGBA32
+// ladder, each rung gated on the GPU advertising it.
+//
+// IMPORTANT (D3D/ANGLE limitation): BC1-7 (and ETC1, also a 4x4-block format)
+// require the BASE mip level's width AND height to be multiples of 4. ANGLE/D3D
+// (Windows desktop browsers) rejects a non-multiple-of-4 base (e.g. 700x999) with
+// GL_INVALID_OPERATION. So when the base mip isn't 4-aligned we skip the 4x4-block
+// formats and fall back to uncompressed RGBA32 (which works at any size). ASTC has
+// no such restriction -- the hardware handles partial edge blocks -- so it is used
+// at any size, which is the common case on mobile.
+// (Whether a D3D12 device can create block-compressed textures with unaligned
+// dimensions is an OPTIONAL feature, D3D12_FEATURE_DATA_D3D12_OPTIONS8::
+// UnalignedBlockTexturesSupported; WebGL/ANGLE does not expose or rely on it. See
+// https://learn.microsoft.com/en-us/windows/win32/api/d3d12/ns-d3d12-d3d12_feature_data_d3d12_options8 )
+//
+// DEVELOPER WORKAROUND for a NON-mipmapped texture (levels == 1): you CAN still use
+// BC7/ETC on a non-4-aligned image. Round the texture dimensions UP to a multiple of
+// 4 (the encoder already pads the last block row/col with duplicated edge texels),
+// upload that block-aligned size, and then only SAMPLE the sub-rectangle that is the
+// real image (scale your UVs by imageDim/alignedDim), ignoring the padded tail. This
+// is exactly what our WebGL KTX2/DDS Studio app does. It works because there are no
+// smaller mips whose dimensions the driver would derive.
+//   For a MIPMAPPED texture there is no reliable/simple workaround: the driver computes each
+// mip level's dimensions itself from the (aligned) base, and those derived sizes
+// diverge from the KTX2 mip dimensions (floor(origDim/2^L) vs floor(alignedDim/2^L)),
+// so the block data no longer lines up. Hence: mipmapped + non-4-aligned base -> RGBA32.
+// NOTE: all rungs use LINEAR (non-sRGB) GPU internal formats on purpose — the shader
+// outputs sampled texels straight to the canvas, so the stored sRGB-encoded bytes must
+// be sampled verbatim (an sRGB internal format would decode to linear and display dark).
+function chooseFormat(bw, bh, w, h) {
+   var key = bw + ',' + bh;
+   if (caps.astc && ASTC_GL[key] !== undefined) {
+      var glf = ASTC_GL[key];   // linear enum (no +0x20 sRGB variant) — see note above
+      return { tf: ASTC_TF[key], glFmt: glf, compressed: true, name: astcGLName(glf) };
+   }
+   var base4Aligned = (w % 4 === 0) && (h % 4 === 0);
+   if (caps.bc7 && base4Aligned)
+      return { tf: TF_BC7, glFmt: GL_COMPRESSED_RGBA_BPTC_UNORM, compressed: true, name: 'BC7' };
+   // ETC path: always transcode to ETC1 (RGB). Upload via the native ETC1 GL format
+   // when WEBGL_compressed_texture_etc1 is present; otherwise via the ETC2-RGB enum
+   // (every ETC1 block is a valid ETC2 RGB block) — that's the extension most Android
+   // WebGL2 contexts actually expose, so probing both maximizes device coverage.
+   if ((caps.etc1 || caps.etc2) && base4Aligned) {
+      var etcGl = caps.etc1 ? GL_COMPRESSED_RGB_ETC1_WEBGL : GL_COMPRESSED_RGB8_ETC2;
+      return { tf: TF_ETC1, glFmt: etcGl, compressed: true, name: caps.etc1 ? 'ETC1' : 'ETC2_RGB' };
+   }
+   // Uncompressed fallback: always valid, any dimensions. (Used when no GPU format
+   // is available, OR when the base mip isn't 4-aligned for the BC/ETC path.)
+   return { tf: TF_RGBA32, glFmt: gl.RGBA8, compressed: false, name: 'RGBA8' };
+}
+
+// Best-effort source-format name for the overlay (matches native ktx2_format_name).
+function sourceFormatName(ktx2File, bw, bh) {
+   function has(m) { return typeof ktx2File[m] === 'function'; }
+   try {
+      if (has('isETC1S') && ktx2File.isETC1S()) return 'ETC1S';
+      if (has('isXUASTC_LDR') && ktx2File.isXUASTC_LDR()) return 'XUASTC LDR ' + bw + 'x' + bh;
+      if (has('isASTC_LDR') && ktx2File.isASTC_LDR()) return 'ASTC LDR ' + bw + 'x' + bh;
+      if (has('isUASTC') && ktx2File.isUASTC()) return 'UASTC LDR 4x4';
+      if (has('isXUBC7') && ktx2File.isXUBC7()) return 'XUBC7';
+   } catch (e) { /* fall through */ }
+   return 'LDR ' + bw + 'x' + bh;
+}
+
+// ---------------------------------------------------------------------------
+// Texture loading: fetch .ktx2 -> transcode every mip level -> upload.
+// ---------------------------------------------------------------------------
+function loadKtx2(url) {
+   var myGen = ++loadGen;   // newest load wins; older in-flight loads bail out below
+   currentUrl = url;
+   loading = true;          // updateOverlay shows "Loading…" until this finishes
+   return fetch(url).then(function (resp) {
+      if (!resp.ok) throw new Error('Failed to fetch ' + url + ' (HTTP ' + resp.status + ')');
+      return resp.arrayBuffer();
+   }).then(function (buf) {
+      if (myGen !== loadGen) return;   // a newer load started while we were fetching
+      uploadKtx2(new Uint8Array(buf), url);
+      loading = false;
+   }).catch(function (e) {
+      if (myGen === loadGen) loading = false;
+      showError(e.message || String(e));
+      console.error(e);
+   });
+}
+
+function uploadKtx2(bytes, url) {
+   // A fetch that started before a context loss can resolve after it but before the
+   // restore rebuilds — don't upload into a dead context (the restore's own reload
+   // will publish the texture). "Newest wins" across overlapping loads is already
+   // guaranteed by the loadGen check in loadKtx2 + uploadKtx2 running synchronously.
+   if (gl.isContextLost()) return;
+
+   var ktx2File = new Module.KTX2File(bytes);
+   var tex = null;   // hoisted so the finally can free it if we throw before publishing
+   try {
+      if (!ktx2File.isValid()) throw new Error('Invalid .ktx2 file: ' + url);
+      if (ktx2File.isHDR()) throw new Error('HDR .ktx2 not supported (this sample is LDR-only): ' + url);
+
+      var w = ktx2File.getWidth();
+      var h = ktx2File.getHeight();
+      var levels = ktx2File.getLevels();
+      var bw = ktx2File.getBlockWidth();
+      var bh = ktx2File.getBlockHeight();
+      var deblockId = ktx2File.getDeblockingFilterIndex();
+
+      var fmt = chooseFormat(bw, bh, w, h);
+
+      if (!ktx2File.startTranscoding()) throw new Error('startTranscoding failed: ' + url);
+
+      var noDeblockFlag = Module.basisu_decode_flags.cDecodeFlagsNoDeblockFiltering.value;
+
+      // Build the new texture into a LOCAL first; the currently displayed texture/quad
+      // are only swapped out (and freed) after the upload fully succeeds. So a failed
+      // load leaves the previous image on screen instead of blanking it. (Holding two
+      // textures for the brief overlap is fine — the assets are small.)
+      tex = gl.createTexture();
+      gl.bindTexture(gl.TEXTURE_2D, tex);
+      gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
+
+      // Upload each mip level at its original (unpadded) dimensions. The
+      // transcoder emits ceil(w/4) x ceil(h/4) blocks with the edge blocks padded,
+      // and WebGL computes the expected byte size the same way, so the sizes match.
+      // Deep mips that floor to non-multiple-of-4 dims (e.g. 6x4, 3x2) are fine;
+      // only the BASE mip must be 4-aligned for BC/ETC1, which chooseFormat()
+      // already guaranteed (else it picked RGBA32).
+      for (var lvl = 0; lvl < levels; lvl++) {
+         var info = ktx2File.getImageLevelInfo(lvl, 0, 0);
+         var ow = info.origWidth, oh = info.origHeight;
+         var dstSize = ktx2File.getImageTranscodedSizeInBytes(lvl, 0, 0, fmt.tf);
+         var dst = new Uint8Array(dstSize);
+         // Pass the no-CPU-deblock flag: the GPU shader does the deblocking, so
+         // letting the transcoder also deblock would double-filter.
+         if (!ktx2File.transcodeImageWithFlags(dst, lvl, 0, 0, fmt.tf, noDeblockFlag, -1, -1))
+            throw new Error('transcodeImageWithFlags failed (level ' + lvl + ')');
+
+         if (fmt.compressed)
+            gl.compressedTexImage2D(gl.TEXTURE_2D, lvl, fmt.glFmt, ow, oh, 0, dst);
+         else
+            gl.texImage2D(gl.TEXTURE_2D, lvl, gl.RGBA8, ow, oh, 0, gl.RGBA, gl.UNSIGNED_BYTE, dst);
+      }
+
+      // One getError after the whole chain (each gl.getError() forces a CPU<->GPU
+      // sync, so we avoid doing it per level). The base mip is the usual culprit
+      // (e.g. a non-4-aligned BC base — guarded against in chooseFormat).
+      var err = gl.getError();
+      if (err !== gl.NO_ERROR)
+         throw new Error('texture upload failed (' + w + 'x' + h + ' ' + fmt.name +
+            ', fmt 0x' + fmt.glFmt.toString(16) + '): GL 0x' + err.toString(16));
+
+      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_BASE_LEVEL, 0);
+      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAX_LEVEL, levels - 1);
+      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
+      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
+
+      // Upload succeeded — now free the previous texture and swap in the new one.
+      if (texture) gl.deleteTexture(texture);
+      texture = tex;
+      texInfo = {
+         w: w, h: h, levels: levels, blockW: bw, blockH: bh,
+         deblockId: deblockId,
+         srcFmt: sourceFormatName(ktx2File, bw, bh),
+         gpuFmt: fmt.name
+      };
+
+      // Auto-enable the GPU deblocking filter based on the KTX2 file's stored
+      // DeblockFilterID (KTX2File.getDeblockingFilterIndex()): the encoder sets it
+      // to 1 when the texture was compressed with deblocking-aware encoding, so the
+      // viewer should deblock by default. This mirrors the native C++ sample. The
+      // user can still toggle it (key 1 / the Deblock button); we capture the auto
+      // value as the Space-reset default.
+      g.const0[0] = (deblockId === 1) ? 1.0 : 0.0;
+      initConst0 = g.const0.slice();
+
+      freeMesh(quad);
+      quad = createQuad(w / h);
+      applyFilterMode();
+
+      hideError();
+      console.log('Loaded ' + url + ': ' + w + 'x' + h + ' levels=' + levels +
+         ' block=' + bw + 'x' + bh + ' src=' + texInfo.srcFmt + ' gpu=' + fmt.name +
+         ' deblockID=' + deblockId);
+   } finally {
+      // If we threw after creating the new texture but before publishing it (texture
+      // still points at the old one, or null), free the orphaned handle so failed
+      // loads don't leak a GL texture.
+      if (tex && texture !== tex) gl.deleteTexture(tex);
+      ktx2File.close();
+      ktx2File.delete();
+   }
+}
+
+function applyFilterMode() {
+   if (!texture) return;
+   gl.bindTexture(gl.TEXTURE_2D, texture);
+   var mips = texInfo.levels > 1;
+   if (g.filterMode === 1) {        // bilinear
+      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, mips ? gl.LINEAR_MIPMAP_NEAREST : gl.LINEAR);
+      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
+   } else if (g.filterMode === 2) { // trilinear
+      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, mips ? gl.LINEAR_MIPMAP_LINEAR : gl.LINEAR);
+      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
+   } else {                         // point
+      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, mips ? gl.NEAREST_MIPMAP_NEAREST : gl.NEAREST);
+      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
+   }
+}
+
+// ---------------------------------------------------------------------------
+// Rendering.
+// ---------------------------------------------------------------------------
+function resizeCanvas() {
+   // Cap DPR: on a DPR-3 phone an uncapped backing store can be ~25M pixels, which
+   // (with the 5-tap deblock shader) risks context loss / thermal throttling.
+   var dpr = Math.min(window.devicePixelRatio || 1, 2);
+   var w = Math.max(1, Math.floor(canvas.clientWidth * dpr));
+   var h = Math.max(1, Math.floor(canvas.clientHeight * dpr));
+   if (canvas.width !== w || canvas.height !== h) {
+      canvas.width = w;
+      canvas.height = h;
+   }
+}
+
+function draw() {
+   resizeCanvas();
+   gl.viewport(0, 0, canvas.width, canvas.height);
+   gl.clearColor(0.2, 0.2, 0.2, 1.0);
+   gl.enable(gl.DEPTH_TEST);
+   gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
+
+   if (!texture || !prog) return;
+
+   gl.useProgram(prog.program);
+
+   var aspect = canvas.width / canvas.height;
+   mat4Perspective(FOV_DEGREES, aspect, Z_NEAR, Z_FAR, _proj);
+   mat4Translate(g.x, g.y, g.z, _trans);
+   mat4RotY(g.yaw, _rotY);
+   mat4RotX(g.pitch, _rotX);
+   mat4Multiply(_rotY, _rotX, _rot);       // model = T * (RotY * RotX)
+   mat4Multiply(_trans, _rot, _model);
+   mat4Multiply(_proj, _model, _mvp);      // mvp = proj * model
+
+   var u = prog.uniforms;
+   if (u.mvp) gl.uniformMatrix4fv(u.mvp, false, _mvp);
+   if (u.tex) gl.uniform1i(u.tex, 0);
+   if (u.texSize) gl.uniform4f(u.texSize, texInfo.w, texInfo.h, texInfo.blockW, texInfo.blockH);
+   if (u.maxLod) gl.uniform1f(u.maxLod, texInfo.levels - 1);
+   if (u.const0) gl.uniform4f(u.const0, g.const0[0], g.const0[1], g.const0[2], g.const0[3]);
+   if (u.const1) gl.uniform4f(u.const1, g.const1[0], g.const1[1], g.const1[2], g.const1[3]);
+
+   gl.activeTexture(gl.TEXTURE0);
+   gl.bindTexture(gl.TEXTURE_2D, texture);
+
+   var mesh = g.cube ? cube : quad;
+   gl.bindVertexArray(mesh.vao);
+   gl.drawElements(gl.TRIANGLES, mesh.count, gl.UNSIGNED_SHORT, 0);
+   gl.bindVertexArray(null);
+}
+
+function filterName(m) { return m === 0 ? 'POINT' : (m === 2 ? 'TRILINEAR' : 'BILINEAR'); }
+
+function updateOverlay() {
+   if (loading) {
+      document.getElementById('info').textContent = 'Loading ' + currentUrl + ' …';
+      return;
+   }
+   var c0 = g.const0.map(function (v) { return v ? 1 : 0; }).join('');
+   var c1 = g.const1.map(function (v) { return v ? 1 : 0; }).join('');
+   var l0 = 'Res: ' + texInfo.w + 'x' + texInfo.h + '   Mips: ' + texInfo.levels +
+      '   Block: ' + texInfo.blockW + 'x' + texInfo.blockH +
+      '   DeblockID: ' + texInfo.deblockId +
+      '   Src: ' + texInfo.srcFmt + '   GPU: ' + texInfo.gpuFmt;
+   var l1 = 'Mode: ' + (g.cube ? 'CUBE' : 'QUAD') + '   Filter: ' + filterName(g.filterMode) +
+      '   Deblock(1) / Edges(2): [' + c0 + '][' + c1 + ']';
+   var l2 = 'X:' + g.x.toFixed(1) + '  Y:' + g.y.toFixed(1) + '  Z:' + g.z.toFixed(1) +
+      '  Yaw:' + g.yaw.toFixed(1) + '  Pitch:' + g.pitch.toFixed(1);
+   document.getElementById('info').textContent = l0 + '\n' + l1 + '\n' + l2;
+}
+
+// ---------------------------------------------------------------------------
+// Input.
+// ---------------------------------------------------------------------------
+// Discrete actions, shared by the keyboard and the on-screen buttons.
+function toggleCube() { g.cube = !g.cube; }
+function setFilter(m) { g.filterMode = m; applyFilterMode(); }
+function cycleFilter() { setFilter((g.filterMode + 1) % 3); } // point -> bilinear -> trilinear
+function toggleDeblock() { g.const0[0] = 1 - g.const0[0]; }
+function toggleEdges() { g.const0[1] = 1 - g.const0[1]; }
+function resetView() {
+   g.x = 0; g.y = 0; g.z = -0.4; g.yaw = 0; g.pitch = 0;
+   g.const0 = initConst0.slice();
+   g.const1 = [0, 0, 0, 0];
+}
+function clampZ() {
+   if (g.z < Z_MAX) g.z = Z_MAX;
+   if (g.z > Z_MIN) g.z = Z_MIN;
+}
+
+function onKeyDown(e) {
+   // Stop the page from scrolling on arrows/space.
+   if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Space'].indexOf(e.code) >= 0)
+      e.preventDefault();
+
+   heldKeys[e.code] = true;
+
+   if (e.repeat) return; // discrete actions fire once per physical press
+   switch (e.code) {
+      case 'KeyC': toggleCube(); break;
+      case 'KeyB': setFilter(1); break;
+      case 'KeyT': setFilter(2); break;
+      case 'KeyP': setFilter(0); break;
+      case 'Digit1': toggleDeblock(); break;
+      case 'Digit2': toggleEdges(); break;
+      case 'Digit3': g.const0[2] = 1 - g.const0[2]; break;
+      case 'Digit4': g.const0[3] = 1 - g.const0[3]; break;
+      case 'Digit5': g.const1[0] = 1 - g.const1[0]; break;
+      case 'Digit6': g.const1[1] = 1 - g.const1[1]; break;
+      case 'Digit7': g.const1[2] = 1 - g.const1[2]; break;
+      case 'Digit8': g.const1[3] = 1 - g.const1[3]; break;
+      case 'Space': resetView(); break;
+   }
+}
+function onKeyUp(e) { heldKeys[e.code] = false; }
+
+// ---------------------------------------------------------------------------
+// Pointer / touch / wheel controls (mouse + touchscreen, no keyboard needed):
+//   one-finger / left-drag  -> rotate (yaw + pitch)
+//   two-finger pinch        -> zoom; two-finger drag -> pan
+//   mouse wheel             -> zoom
+// ---------------------------------------------------------------------------
+var ROT_DRAG_MOUSE = 0.5;  // degrees of rotation per pixel (mouse)
+var ROT_DRAG_TOUCH = 0.25; // gentler for touch — a finger flick covers more pixels
+var PINCH_ZOOM = 0.012; // z units per pixel of pinch spread
+var PAN_SPEED = 0.004;  // world units per pixel of two-finger drag
+var WHEEL_ZOOM = 0.0025;
+
+var pointers = {};      // active pointerId -> {x, y}
+var pinchDist = 0;
+var panMid = null;
+
+function pointerCount() { return Object.keys(pointers).length; }
+
+function onPointerDown(e) {
+   pointers[e.pointerId] = { x: e.clientX, y: e.clientY };
+   // Any change in finger count re-seeds the pinch/pan baseline on the next move, so
+   // adding/removing a finger (1<->2<->3) never produces a one-frame zoom/pan jump.
+   pinchDist = 0; panMid = null;
+   // Capture so we still get pointermove/up even if the finger leaves the canvas;
+   // otherwise a missed pointerup leaves a stale entry that breaks rotate forever.
+   try { canvas.setPointerCapture(e.pointerId); } catch (err) { /* not fatal */ }
+   e.preventDefault();
+}
+function onPointerMove(e) {
+   var p = pointers[e.pointerId];
+   if (!p) return;
+   var nx = e.clientX, ny = e.clientY;
+   var n = pointerCount();
+
+   if (n === 1) {
+      var rot = (e.pointerType === 'touch') ? ROT_DRAG_TOUCH : ROT_DRAG_MOUSE;
+      g.yaw += (nx - p.x) * rot;
+      g.pitch += (ny - p.y) * rot;
+   }
+   p.x = nx; p.y = ny;
+
+   if (n >= 2) {
+      var ids = Object.keys(pointers);
+      var a = pointers[ids[0]], b = pointers[ids[1]];
+      var dist = Math.hypot(a.x - b.x, a.y - b.y);
+      var midx = (a.x + b.x) / 2, midy = (a.y + b.y) / 2;
+      if (pinchDist > 0) {
+         g.z += (dist - pinchDist) * PINCH_ZOOM;     // spread fingers -> move closer
+         if (panMid) { g.x += (midx - panMid.x) * PAN_SPEED; g.y -= (midy - panMid.y) * PAN_SPEED; }
+         clampZ();
+      }
+      pinchDist = dist;
+      panMid = { x: midx, y: midy };
+   }
+   e.preventDefault();
+}
+function onPointerUp(e) {
+   try { canvas.releasePointerCapture(e.pointerId); } catch (err) { /* ignore */ }
+   delete pointers[e.pointerId];
+   pinchDist = 0; panMid = null;   // re-seed pinch/pan baseline after any finger lifts
+}
+function onWheel(e) {
+   g.z -= e.deltaY * WHEEL_ZOOM; // wheel up -> closer
+   clampZ();
+   e.preventDefault();
+}
+
+// Keep the on-screen buttons' labels/active state in sync with the model state
+// (so keyboard and touch stay consistent).
+function syncButtons() {
+   // Compact labels so the 5 buttons fit one horizontal row on a phone. Deblock/Edges
+   // on-state is shown by the green .active highlight (and also in the top-left overlay).
+   var b;
+   if ((b = document.getElementById('btnMode'))) b.textContent = g.cube ? 'Cube' : 'Quad';
+   if ((b = document.getElementById('btnDeblock'))) {
+      b.textContent = 'Deblock';
+      b.classList.toggle('active', !!g.const0[0]);
+   }
+   if ((b = document.getElementById('btnEdges'))) {
+      b.textContent = 'Edges';
+      b.classList.toggle('active', !!g.const0[1]);
+   }
+   if ((b = document.getElementById('btnFilter'))) b.textContent = filterName(g.filterMode);
+}
+
+function processHeldKeys(dt) {
+   if (heldKeys['ShiftLeft'] || heldKeys['ShiftRight']) dt *= 1.0 / 3.0;
+   if (heldKeys['KeyW']) g.z += Z_SPEED * dt;
+   if (heldKeys['KeyS']) g.z -= Z_SPEED * dt;
+   if (heldKeys['ArrowLeft']) g.x += XY_SPEED * dt;
+   if (heldKeys['ArrowRight']) g.x -= XY_SPEED * dt;
+   if (heldKeys['ArrowUp']) g.y += XY_SPEED * dt;
+   if (heldKeys['ArrowDown']) g.y -= XY_SPEED * dt;
+   if (heldKeys['KeyA']) g.yaw += ROT_SPEED * dt;
+   if (heldKeys['KeyD']) g.yaw -= ROT_SPEED * dt;
+   if (heldKeys['KeyQ']) g.pitch += ROT_SPEED * dt;
+   if (heldKeys['KeyE']) g.pitch -= ROT_SPEED * dt;
+   if (g.z < Z_MAX) g.z = Z_MAX;
+   if (g.z > Z_MIN) g.z = Z_MIN;
+}
+
+// ---------------------------------------------------------------------------
+// Error overlay.
+// ---------------------------------------------------------------------------
+function showError(msg) {
+   var e = document.getElementById('error');
+   e.textContent = msg;
+   e.style.display = 'block';
+}
+function hideError() { document.getElementById('error').style.display = 'none'; }
+
+// ---------------------------------------------------------------------------
+// Main loop + bootstrap.
+// ---------------------------------------------------------------------------
+function frame(now) {
+   var dt = (now - lastTime) / 1000.0;
+   lastTime = now;
+   if (dt > 0.1) dt = 0.1; // cap after a stall to avoid a teleport
+   processHeldKeys(dt);
+   draw();
+   updateOverlay();
+   syncButtons();
+   rafId = requestAnimationFrame(frame);
+}
+
+// (Re)create all GL objects. Called at startup and again after a context restore,
+// where every prior GL handle has been invalidated.
+function initGL() {
+   detectCaps();
+   prog = buildProgram(VERTEX_SHADER_SRC, FRAGMENT_SHADER_SRC);
+   cube = createCube();
+}
+
+function start() {
+   canvas = document.getElementById('canvas');
+   // Drop MSAA on high-DPR screens (it adds little at DPR>=2 and doubles bandwidth on
+   // tile-based mobile GPUs); the DPR cap in resizeCanvas keeps the buffer reasonable.
+   var wantAA = (window.devicePixelRatio || 1) < 2;
+   gl = canvas.getContext('webgl2', { alpha: false, depth: true, antialias: wantAA });
+   if (!gl) {
+      showError('WebGL 2 is required for this sample (#version 300 es shaders), but ' +
+         'this browser/device did not provide a WebGL 2 context.');
+      return;
+   }
+
+   initGL();
+
+   // Mobile browsers drop the GL context when backgrounded or under memory pressure.
+   // Without preventDefault() the context is never restored (permanently black canvas);
+   // on restore every GL object is invalid and must be rebuilt.
+   canvas.addEventListener('webglcontextlost', function (e) {
+      e.preventDefault();
+      if (rafId) { cancelAnimationFrame(rafId); rafId = 0; }
+      loading = false;   // don't let a "Loading…" overlay stay wedged if loss hit mid-load
+   }, false);
+   canvas.addEventListener('webglcontextrestored', function () {
+      // A throw in here (e.g. shader relink fails under the same memory pressure that
+      // caused the loss) would otherwise never restart the loop -> permanent freeze
+      // with no feedback. Catch it and tell the user.
+      try {
+         texture = null; quad = null;   // old handles are dead
+         initGL();
+         loadKtx2(currentUrl);
+         lastTime = performance.now();
+         rafId = requestAnimationFrame(frame);
+      } catch (err) {
+         showError('The GPU context was lost and could not be rebuilt — please reload the page.');
+         console.error('context restore failed:', err);
+      }
+   }, false);
+
+   // Show touch-only help on coarse-pointer devices (the keyboard line is noise on
+   // a phone/tablet); show the full keyboard reference on mouse/desktop.
+   var coarse = window.matchMedia && window.matchMedia('(pointer: coarse)').matches;
+   document.getElementById('help').textContent = coarse
+      ? 'Drag: rotate    Two-finger pinch: zoom / drag: pan    Buttons below'
+      : 'Drag: rotate   Two-finger pinch: zoom / drag: pan   Wheel: zoom\n' +
+        'Keys — Move: Arrows/W,S   Rotate: A,D / Q,E   Shift: slow   C B T P 1 2   Space: reset';
+
+   document.getElementById('asset').addEventListener('change', function () {
+      loadKtx2(this.value);
+   });
+
+   // On-screen buttons (so the sample is fully usable on phones/tablets).
+   var bind = function (id, fn) {
+      var el = document.getElementById(id);
+      if (el) el.addEventListener('click', fn);
+   };
+   bind('btnMode', toggleCube);
+   bind('btnDeblock', toggleDeblock);
+   bind('btnEdges', toggleEdges);
+   bind('btnFilter', cycleFilter);
+   bind('btnReset', resetView);
+
+   window.addEventListener('keydown', onKeyDown);
+   window.addEventListener('keyup', onKeyUp);
+
+   // Pointer + wheel controls on the canvas (mouse and touch).
+   canvas.addEventListener('pointerdown', onPointerDown);
+   canvas.addEventListener('pointermove', onPointerMove);
+   canvas.addEventListener('pointerup', onPointerUp);
+   canvas.addEventListener('pointercancel', onPointerUp);
+   // No 'pointerleave' handler: with setPointerCapture the canvas keeps receiving
+   // events when the finger/cursor leaves its bounds, so deleting the pointer on
+   // leave would wrongly abort an in-progress drag.
+   canvas.addEventListener('wheel', onWheel, { passive: false });
+   // Suppress the long-press/right-click context menu so it can't interrupt a drag
+   // (Android long-press, desktop right-drag).
+   canvas.addEventListener('contextmenu', function (e) { e.preventDefault(); });
+
+   loadKtx2(DEFAULT_ASSET);
+
+   lastTime = performance.now();
+   rafId = requestAnimationFrame(frame);
+}
+
+// Boot the Basis Universal WASM transcoder, then start (the BASIS() factory and
+// initializeBasis() are the shared bootstrap used by the other webgl/ samples).
+if (typeof BASIS === 'undefined') {
+   // The transcoder <script> failed to load (blocked, 404, wrong path). Without
+   // this guard the next line throws a ReferenceError before we can surface it.
+   showError('Could not load the Basis transcoder script ' +
+      '(../transcoder/build/basis_transcoder.js). Serve this page over HTTP from the ' +
+      'webgl/ directory so the relative path resolves.');
+} else {
+   BASIS({
+      onRuntimeInitialized: function () { }
+   }).then(function (module) {
+      Module = module;
+      Module.initializeBasis();
+      start();
+   }).catch(function (e) {
+      showError('Failed to initialize the Basis transcoder WASM module: ' + (e.message || e));
+      console.error(e);
+   });
+}