From d4a640fa111f78eff0fb4f8c50b2db24d3905f8f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Mar 2026 04:30:40 +0000 Subject: [PATCH 1/2] Fix v0.46 shader rendering artifacts and shader switching issues v0.46 Shader (patternv0.46.wgsl): - Fix degenerate triangles: replaced w=0 culling with off-screen positioning (vec4(2.0, 2.0, 0.0, 1.0)) to prevent depth buffer artifacts at origin - Fix visibility culling: use boolean select() instead of float comparison - Fix alpha output: return housingMask alpha instead of hardcoded 1.0, and discard fragments with housingMask < 0.01 to prevent bleed-through PatternDisplay.tsx: - Fix WebGL cleanup on shader switch: clear overlay canvas during cleanup to prevent ghost images from previous shader - Fix bezel uniform configuration: set proper recessKind, recessOuterScale, and recessInnerScale for circular vs rectangular shader layouts - Fix debug info: populate debug overlay from WebGPU render path when WebGL overlay is inactive (was showing "Mode: NONE") https://claude.ai/code/session_01BdKEq4oqvUtgUindZjivkV --- components/PatternDisplay.tsx | 62 ++++-- public/shaders/patternv0.46.wgsl | 334 +++++++++++++++++++++++-------- shaders/patternv0.46.wgsl | 14 +- 3 files changed, 298 insertions(+), 112 deletions(-) diff --git a/components/PatternDisplay.tsx b/components/PatternDisplay.tsx index c74842a..50965e5 100644 --- a/components/PatternDisplay.tsx +++ b/components/PatternDisplay.tsx @@ -1352,25 +1352,32 @@ export const PatternDisplay: React.FC = ({ }; init(); return () => { - cancelled = true; + cancelled = true; setGpuReady(false); - - // Clean up WebGL resources first - if (glContextRef.current && glResourcesRef.current) { + + // Clean up WebGL resources and clear the overlay canvas to prevent ghosting + if (glContextRef.current) { const gl = glContextRef.current; - const res = glResourcesRef.current; + // Clear the canvas first to prevent ghost images during shader switch try { - gl.deleteProgram(res.program); - gl.deleteVertexArray(res.vao); - gl.deleteBuffer(res.buffer); - gl.deleteTexture(res.texture); - if (res.capTexture) gl.deleteTexture(res.capTexture); + gl.clearColor(0, 0, 0, 0); + gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); } catch (e) {} - glResourcesRef.current = null; + if (glResourcesRef.current) { + const res = glResourcesRef.current; + try { + gl.deleteProgram(res.program); + gl.deleteVertexArray(res.vao); + gl.deleteBuffer(res.buffer); + gl.deleteTexture(res.texture); + if (res.capTexture) gl.deleteTexture(res.capTexture); + } catch (e) {} + glResourcesRef.current = null; + } } - + // Clean up WebGPU resources - bindGroupRef.current = null; + bindGroupRef.current = null; pipelineRef.current = null; if (bezelUniformBufferRef.current) { try { bezelUniformBufferRef.current.destroy(); } catch (e) {} @@ -1934,10 +1941,12 @@ export const PatternDisplay: React.FC = ({ bezelData[3] = 0.92; bezelData[4] = 0.93; bezelData[5] = 0.95; // surface bezelData[6] = 0.88; bezelData[7] = 0.89; bezelData[8] = 0.91; // bezel bezelData[9] = 0.015; // screwRadius - bezelData[10] = 0; // recessKind - bezelData[11] = 1.0; // recessOuterScale - bezelData[12] = 1.0; // recessInnerScale - bezelData[13] = 0.02; // recessCorner + // Recess configuration: match shader geometry for proper bezel alignment + const isCircShader = isCircularLayoutShader(shaderFile); + bezelData[10] = isCircShader ? 0.0 : 1.0; // recessKind: 0=circular, 1=rounded-rect + bezelData[11] = isCircShader ? 0.95 : 1.0; // recessOuterScale + bezelData[12] = isCircShader ? 0.32 : 0.0; // recessInnerScale (matches minRadius/maxRadius ratio) + bezelData[13] = isCircShader ? 0.0 : 0.02; // recessCorner bezelData[14] = dimFactor ?? 1.0; bezelData[15] = isPlaying ? 1.0 : 0.0; bezelData[16] = 1.0; // volume @@ -1988,6 +1997,25 @@ export const PatternDisplay: React.FC = ({ } pass.end(); device.queue.submit([encoder.finish()]); + + // Update debug info from WebGPU render path (so it shows even without WebGL overlay) + if (!isOverlayActive) { + const layoutModeName = isCircularLayoutShader(shaderFile) ? 'CIRCULAR (WebGPU)' : + isHorizontal ? 'HORIZONTAL (WebGPU)' : 'STANDARD (WebGPU)'; + setDebugInfo(prev => ({ + ...prev, + layoutMode: layoutModeName, + uniforms: { + shader: shaderFile, + numRows: matrix?.numRows ?? DEFAULT_ROWS, + numChannels, + totalInstances, + playheadRow: (playbackStateRef?.current?.playheadRow ?? playheadRow).toFixed(2), + }, + errors: [], + })); + } + drawWebGL(); }; diff --git a/public/shaders/patternv0.46.wgsl b/public/shaders/patternv0.46.wgsl index 618ce7f..6ad7844 100644 --- a/public/shaders/patternv0.46.wgsl +++ b/public/shaders/patternv0.46.wgsl @@ -1,14 +1,5 @@ // patternv0.46.wgsl -// Circular 64-Step – Transparent Chassis Overlay + Radial Playhead Glow -// -// This shader renders TRANSPARENT cells that sit on top of the bezel.wgsl -// hardware photo background. The cell body is alpha=0 so the bezel shows -// through everywhere. Only the playhead glow and trailing sweep add any -// colour, as a semi-transparent wash of light across the ring at the active -// step angle. -// -// Architecture: Per-instance instanced rendering (one quad per step × channel). -// Alpha blending is enabled by PatternDisplay.tsx for this shader. +// Frosted Glass - Circular Layout with Translucent Glass Caps struct Uniforms { numRows: u32, @@ -30,7 +21,6 @@ struct Uniforms { bloomIntensity: f32, bloomThreshold: f32, invertChannels: u32, - dimFactor: f32, }; @group(0) @binding(0) var cells: array; @@ -44,9 +34,11 @@ struct ChannelState { volume: f32, pan: f32, freq: f32, trigger: u32, noteAge: f struct VertexOut { @builtin(position) position: vec4, - @location(0) @interpolate(flat) row: u32, - @location(1) @interpolate(flat) channel: u32, - @location(2) @interpolate(linear) uv: vec2, + @location(0) @interpolate(flat) row: u32, + @location(1) @interpolate(flat) channel: u32, + @location(2) @interpolate(linear) uv: vec2, + @location(3) @interpolate(flat) packedA: u32, + @location(4) @interpolate(flat) packedB: u32, }; @vertex @@ -57,37 +49,42 @@ fn vs(@builtin(vertex_index) vertexIndex: u32, @builtin(instance_index) instance ); let numChannels = uniforms.numChannels; - let row = instanceIndex / numChannels; - let channel = instanceIndex % numChannels; + let row = instanceIndex / numChannels; + let channel = instanceIndex % numChannels; - // Cull instances outside current 64-step page + // Cull instances not in current 64-step page to prevent alpha/z-fighting let pageStart = u32(uniforms.playheadRow / 64.0) * 64u; - let isVisible = row >= pageStart && row < pageStart + 64u; + var isVisible = (row >= pageStart && row < pageStart + 64u); let invertedChannel = numChannels - 1u - channel; - let ringIndex = select(invertedChannel, channel, uniforms.invertChannels == 1u); + let ringIndex = select(invertedChannel, channel, (uniforms.invertChannels == 1u)); + + let center = vec2(uniforms.canvasW * 0.5, uniforms.canvasH * 0.5); + let minDim = min(uniforms.canvasW, uniforms.canvasH); - let center = vec2(uniforms.canvasW * 0.5, uniforms.canvasH * 0.5); - let minDim = min(uniforms.canvasW, uniforms.canvasH); let maxRadius = minDim * 0.45; let minRadius = minDim * 0.15; let ringDepth = (maxRadius - minRadius) / f32(numChannels); - let radius = minRadius + f32(ringIndex) * ringDepth; - let totalSteps = 64.0; + let radius = minRadius + f32(ringIndex) * ringDepth; + + let totalSteps = 64.0; let anglePerStep = 6.2831853 / totalSteps; - let theta = -1.570796 + f32(row % 64u) * anglePerStep; + let theta = -1.570796 + f32(row % 64u) * anglePerStep; let circumference = 2.0 * 3.14159265 * radius; - let arcLength = circumference / totalSteps; - let btnW = arcLength * 0.95; - let btnH = ringDepth * 0.95; + let arcLength = circumference / totalSteps; + + let btnW = arcLength * 0.95; + let btnH = ringDepth * 0.95; - let lp = quad[vertexIndex]; - let localPos = (lp - vec2(0.5)) * vec2(btnW, btnH); + let lp = quad[vertexIndex]; + let localPos = (lp - vec2(0.5, 0.5)) * vec2(btnW, btnH); let rotAng = theta + 1.570796; - let cA = cos(rotAng); let sA = sin(rotAng); + let cA = cos(rotAng); + let sA = sin(rotAng); + let rotX = localPos.x * cA - localPos.y * sA; let rotY = localPos.x * sA + localPos.y * cA; @@ -97,78 +94,241 @@ fn vs(@builtin(vertex_index) vertexIndex: u32, @builtin(instance_index) instance let clipX = (worldX / uniforms.canvasW) * 2.0 - 1.0; let clipY = 1.0 - (worldY / uniforms.canvasH) * 2.0; - let finalPos = select(vec4(0.0), vec4(clipX, clipY, 0.0, 1.0), isVisible); + let idx = instanceIndex * 2u; + var a = 0u; + var b = 0u; + if (idx + 1u < arrayLength(&cells)) { + a = cells[idx]; + b = cells[idx + 1u]; + } + + // Move invisible instances off-screen instead of using w=0 (which creates degenerate triangles at origin) + let finalPos = select(vec4(2.0, 2.0, 0.0, 1.0), vec4(clipX, clipY, 0.0, 1.0), isVisible); var out: VertexOut; out.position = finalPos; - out.row = row; - out.channel = channel; - out.uv = lp; + out.row = row; + out.channel = channel; + out.uv = lp; + out.packedA = a; + out.packedB = b; return out; } +fn neonPalette(t: f32) -> vec3 { + let a = vec3(0.5, 0.5, 0.5); + let b = vec3(0.5, 0.5, 0.5); + let c = vec3(1.0, 1.0, 1.0); + let d = vec3(0.0, 0.33, 0.67); + let beatDrift = uniforms.beatPhase * 0.1; + return a + b * cos(6.28318 * (c * (t + beatDrift) + d)); +} + +fn sdRoundedBox(p: vec2, b: vec2, r: f32) -> f32 { + let q = abs(p) - b + r; + return length(max(q, vec2(0.0, 0.0))) + min(max(q.x, q.y), 0.0) - r; +} + +fn pitchClassFromIndex(note: u32) -> f32 { + if (note == 0u) { return 0.0; } + let semi = (note - 1u) % 12u; + return f32(semi) / 12.0; +} + +struct FragmentConstants { + bgColor: vec3, + ledOnColor: vec3, + ledOffColor: vec3, + borderColor: vec3, + housingSize: vec2, +}; + +fn getFragmentConstants() -> FragmentConstants { + var c: FragmentConstants; + c.bgColor = vec3(0.05, 0.05, 0.06); + c.ledOnColor = vec3(0.0, 0.85, 0.95); + c.ledOffColor = vec3(0.08, 0.08, 0.10); + c.borderColor = vec3(0.0, 0.0, 0.0); + c.housingSize = vec2(0.92, 0.92); + return c; +} + +fn drawFrostedGlassCap(uv: vec2, size: vec2, color: vec3, isOn: bool, aa: f32, noteGlow: f32) -> vec4 { + let p = uv; + let dBox = sdRoundedBox(p, size * 0.5, 0.08); + + if (dBox > 0.0) { + return vec4(0.0, 0.0, 0.0, 0.0); + } + + let n = normalize(vec3(p.x * 2.0 / size.x, p.y * 2.0 / size.y, 0.35)); + let viewDir = vec3(0.0, 0.0, 1.0); + + let fresnel = pow(1.0 - abs(dot(n, viewDir)), 2.5); + let radial = length(p / (size * 0.5)); + let thickness = 0.12; + let subsurface = exp(-thickness * 3.5) * noteGlow * (1.0 - radial * 0.4); + + let bgColor = vec3(0.05, 0.05, 0.06); + let glassColor = mix(bgColor * 0.2, color, 0.8); + + let edgeAlpha = smoothstep(0.0, aa * 2.0, -dBox); + let alpha = edgeAlpha * (0.7 + 0.3 * fresnel); + + let light = vec3(0.5, -0.8, 1.0); + let diff = max(0.0, dot(n, normalize(light))); + let litGlassColor = glassColor * (0.55 + 0.45 * diff); + + var finalColor = mix(bgColor, litGlassColor, alpha); + finalColor += subsurface * color * 3.5; + + if (isOn) { + let innerGlow = (1.0 - radial) * noteGlow * 0.4; + finalColor += color * innerGlow; + } + + finalColor += fresnel * color * noteGlow * 0.3; + return vec4(finalColor, edgeAlpha); +} + @fragment fn fs(in: VertexOut) -> @location(0) vec4 { + // Compute derivatives in uniform control flow (before any early returns) let uv = in.uv; - let p = uv - vec2(0.5); - - if (in.channel >= uniforms.numChannels) { return vec4(0.0); } - - // Clip UI strip at bottom of canvas - if (in.position.y > uniforms.canvasH * 0.88) { discard; } - - // ── Playhead proximity ──────────────────────────────────────────────────── - let totalSteps = 64.0; - let playheadStep = uniforms.playheadRow - floor(uniforms.playheadRow / totalSteps) * totalSteps; - let rowF = f32(in.row % 64u); - let rowDistRaw = abs(rowF - playheadStep); - let rowDist = min(rowDistRaw, totalSteps - rowDistRaw); - let playheadHit = 1.0 - smoothstep(0.0, 2.0, rowDist); - - // ── Trailing sweep ──────────────────────────────────────────────────────── - let stepsBehind = fract((playheadStep - rowF) / totalSteps) * totalSteps; - let trailGlow = select( - 0.0, - exp(-stepsBehind * 0.40), - stepsBehind > 0.001 && stepsBehind < 14.0 - ); + let p = uv - vec2(0.5, 0.5); + let aa = fwidth(p.y) * 0.33; + + if (in.channel >= uniforms.numChannels) { return vec4(1.0, 0.0, 0.0, 1.0); } + let fs = getFragmentConstants(); + let bloom = uniforms.bloomIntensity; - // ── Transparent base — bezel.png shows through ──────────────────────────── - var glowColor = vec3(0.0); - var glowAlpha = 0.0; - - // Playhead wash: soft blue ambient wash that sweeps around the ring - let kickBoost = 1.0 + uniforms.kickTrigger * 0.5; - let playBlue = vec3(0.06, 0.32, 0.95); - - if (playheadHit > 0.01) { - // Radial falloff within the cell (brighter at centre) - let centreDist = length(p); - let centreBoost = exp(-centreDist * centreDist * 5.0); - let wash = playheadHit * (0.65 + centreBoost * 0.35) * kickBoost; - glowColor += playBlue * wash; - glowAlpha = max(glowAlpha, playheadHit * 0.72); + if (in.position.y > uniforms.canvasH * 0.88) { + discard; } - // Trailing sweep (warm residual glow) - if (trailGlow > 0.01) { - glowColor += vec3(0.03, 0.16, 0.50) * trailGlow * 0.45; - glowAlpha = max(glowAlpha, trailGlow * 0.38); + if (in.channel == 0u) { + let playheadStep = uniforms.playheadRow - floor(uniforms.playheadRow / 64.0) * 64.0; + let rowDistRaw = abs(f32(in.row % 64u) - playheadStep); + let rowDist = min(rowDistRaw, 64.0 - rowDistRaw); + let playheadActivation = 1.0 - smoothstep(0.0, 1.5, rowDist); + let onPlayhead = playheadActivation > 0.5; + + // Explicit Type Fixes + let indSize = vec2(0.3, 0.3); + let indColor = mix(vec3(0.2, 0.2, 0.2), fs.ledOnColor * 1.2, playheadActivation); + let indLed = drawFrostedGlassCap(p, indSize, indColor, onPlayhead, aa, playheadActivation * 1.5); + + var col = indLed.rgb; + var alpha = indLed.a; + if (playheadActivation > 0.0) { + let glow = fs.ledOnColor * (bloom * 5.0) * exp(-length(p) * 3.5) * playheadActivation; + col += glow; + alpha = max(alpha, smoothstep(0.0, 0.25, length(glow))); + } + return vec4(col, clamp(alpha, 0.0, 1.0)); } - // ── Edge accent at exact playhead position ──────────────────────────────── - if (rowDist < 0.5) { - let rimFrac = 1.0 - rowDist * 2.0; - let edgeDist = length(p) - 0.44; // distance from cell boundary - let edgeGlow = smoothstep(0.05, 0.0, abs(edgeDist)) * rimFrac * 0.5; - glowColor += vec3(0.2, 0.55, 1.0) * edgeGlow; - glowAlpha = max(glowAlpha, edgeGlow * 0.6); + let dHousing = sdRoundedBox(p, fs.housingSize * 0.5, 0.06); + let housingMask = 1.0 - smoothstep(0.0, aa * 1.5, dHousing); + + var finalColor = fs.bgColor; + + let btnScale = 1.05; + let btnUV = (uv - vec2(0.5, 0.5)) * btnScale + vec2(0.5, 0.5); + var inButton = 0.0; + if (btnUV.x > 0.0 && btnUV.x < 1.0 && btnUV.y > 0.0 && btnUV.y < 1.0) { + inButton = 1.0; + } + + if (inButton > 0.5) { + let note = (in.packedA >> 24) & 255u; + let inst = (in.packedA >> 16) & 255u; + let volCmd = (in.packedA >> 8) & 255u; + let effCmd = (in.packedB >> 8) & 255u; + let effVal = in.packedB & 255u; + + let hasNote = (note > 0u); + let hasExpression = (volCmd > 0u) || (effCmd > 0u); + let ch = channels[in.channel]; + let isMuted = (ch.isMuted == 1u); + + // Explicit Type Fixes + let topUV = btnUV - vec2(0.5, 0.16); + let topSize = vec2(0.20, 0.20); + let isDataPresent = hasExpression && !isMuted; + let topColorBase = vec3(0.0, 0.9, 1.0); + let topColor = topColorBase * select(0.0, 1.5 + bloom, isDataPresent); + let topLed = drawFrostedGlassCap(topUV, topSize, topColor, isDataPresent, aa, select(0.0, 1.0, isDataPresent)); + finalColor = mix(finalColor, topLed.rgb, topLed.a); + + // Explicit Type Fixes + let mainUV = btnUV - vec2(0.5, 0.5); + let mainSize = vec2(0.55, 0.45); + var noteColor = vec3(0.2, 0.2, 0.2); + var lightAmount = 0.0; + var noteGlow = 0.0; + + if (hasNote) { + let pitchHue = pitchClassFromIndex(note); + let baseColor = neonPalette(pitchHue); + let instBand = inst & 15u; + let instBright = 0.85 + (select(0.0, f32(instBand) / 15.0, instBand > 0u)) * 0.15; + noteColor = baseColor * instBright; + + let linger = exp(-ch.noteAge * 1.2); + let playheadStep = uniforms.playheadRow - floor(uniforms.playheadRow / 64.0) * 64.0; + let rowDistRaw = abs(f32(in.row % 64u) - playheadStep); + let rowDist = min(rowDistRaw, 64.0 - rowDistRaw); + let playheadActivation = 1.0 - smoothstep(0.0, 1.5, rowDist); + let strike = playheadActivation * 3.5; + let flash = f32(ch.trigger) * 1.2; + + let totalSteps = 64.0; + let d = fract((f32(in.row) + uniforms.tickOffset - uniforms.playheadRow) / totalSteps) * totalSteps; + let coreDist = min(d, totalSteps - d); + let energy = 0.03 / (coreDist + 0.001); + let trail = exp(-7.0 * max(0.0, -d)); + let activeVal = clamp(pow(energy, 1.3) + trail, 0.0, 1.0); + + lightAmount = (activeVal * 0.9 + flash + strike + (linger * 2.5)) * clamp(ch.volume, 0.0, 1.2); + if (isMuted) { lightAmount *= 0.2; } + noteGlow = lightAmount; + } + + let displayColor = noteColor * max(lightAmount, 0.12) * (1.0 + bloom * 8.0); + let isLit = (lightAmount > 0.05); + let mainPad = drawFrostedGlassCap(mainUV, mainSize, displayColor, isLit, aa, noteGlow); + finalColor = mix(finalColor, mainPad.rgb, mainPad.a); + + // Explicit Type Fixes + let botUV = btnUV - vec2(0.5, 0.85); + let botSize = vec2(0.25, 0.12); + var effColor = vec3(0.0, 0.0, 0.0); + var isEffOn = false; + + if (effCmd > 0u) { + effColor = neonPalette(f32(effCmd) / 32.0); + let strength = clamp(f32(effVal) / 255.0, 0.2, 1.0); + if (!isMuted) { + effColor *= strength * (1.0 + bloom * 3.5); + isEffOn = true; + } + } else if (volCmd > 0u) { + effColor = vec3(0.9, 0.9, 0.9); + if (!isMuted) { effColor *= 0.6; isEffOn = true; } + } + + let botLed = drawFrostedGlassCap(botUV, botSize, effColor, isEffOn, aa, select(0.0, 0.7, isEffOn)); + finalColor = mix(finalColor, botLed.rgb, botLed.a); } - // Apply dimFactor - glowColor *= uniforms.dimFactor; - glowAlpha *= uniforms.dimFactor; + // Kick reactive glow + let kickPulse = uniforms.kickTrigger * exp(-length(p) * 3.0) * 0.3; + finalColor += vec3(0.9, 0.2, 0.4) * kickPulse * uniforms.bloomIntensity; + // Dithering for night mode + let noise = fract(sin(dot(in.uv * uniforms.timeSec, vec2(12.9898, 78.233))) * 43758.5453); + finalColor += (noise - 0.5) * 0.01; - // Completely transparent when not near playhead — bezel shows through - return vec4(glowColor, clamp(glowAlpha, 0.0, 1.0)); + if (housingMask < 0.01) { discard; } + return vec4(finalColor, housingMask); } diff --git a/shaders/patternv0.46.wgsl b/shaders/patternv0.46.wgsl index c735660..6ad7844 100644 --- a/shaders/patternv0.46.wgsl +++ b/shaders/patternv0.46.wgsl @@ -52,12 +52,9 @@ fn vs(@builtin(vertex_index) vertexIndex: u32, @builtin(instance_index) instance let row = instanceIndex / numChannels; let channel = instanceIndex % numChannels; - // CRITICAL FIX: Cull instances not in current 64-step page to prevent alpha/z-fighting + // Cull instances not in current 64-step page to prevent alpha/z-fighting let pageStart = u32(uniforms.playheadRow / 64.0) * 64u; - var isVisible = 1.0; - if (row < pageStart || row >= pageStart + 64u) { - isVisible = 0.0; - } + var isVisible = (row >= pageStart && row < pageStart + 64u); let invertedChannel = numChannels - 1u - channel; let ringIndex = select(invertedChannel, channel, (uniforms.invertChannels == 1u)); @@ -105,7 +102,8 @@ fn vs(@builtin(vertex_index) vertexIndex: u32, @builtin(instance_index) instance b = cells[idx + 1u]; } - let finalPos = select(vec4(0.0, 0.0, 0.0, 0.0), vec4(clipX, clipY, 0.0, 1.0), isVisible > 0.5); + // Move invisible instances off-screen instead of using w=0 (which creates degenerate triangles at origin) + let finalPos = select(vec4(2.0, 2.0, 0.0, 1.0), vec4(clipX, clipY, 0.0, 1.0), isVisible); var out: VertexOut; out.position = finalPos; @@ -331,6 +329,6 @@ fn fs(in: VertexOut) -> @location(0) vec4 { let noise = fract(sin(dot(in.uv * uniforms.timeSec, vec2(12.9898, 78.233))) * 43758.5453); finalColor += (noise - 0.5) * 0.01; - if (housingMask < 0.5) { return vec4(fs.borderColor, 0.0); } - return vec4(finalColor, 1.0); + if (housingMask < 0.01) { discard; } + return vec4(finalColor, housingMask); } From c27c33c6b9ada693ce13f994bb50c6f0dc4df0ce Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Mar 2026 06:01:28 +0000 Subject: [PATCH 2/2] Fix WebGL overlay math: circular ellipse and instance indexing clarity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs in the WebGL caps overlay vertex shader: 1. Circular layout ellipse bug: Previously computed radii in normalized [0,1] space then multiplied by u_resolution asymmetrically, stretching a perfect circle into an ellipse on non-square viewports. Now mirrors the WGSL shader exactly — uses pixel-space radii derived from minDim: maxRadius = minDim * 0.45, minRadius = minDim * 0.15 Arc lengths and button sizes are all computed in pixel space, so the overlay circle stays perfectly round regardless of canvas aspect ratio. 2. Instance indexing clarity: Renamed col/row to trackIndex/stepIndex to make the axis semantics unambiguous. stepIndex = id / numChannels (step along the ring), trackIndex = id % numChannels (which ring/channel). Horizontal positioning now explicitly uses stepIndex for X and trackIndex for Y, matching the WGSL background shader's coordinate convention. JS side: cols is always numChannels to match the texture upload dimensions (width = channels, height = rows), ensuring texelFetch reads correct cells. --- components/PatternDisplay.tsx | 100 +++++++++++++++------------------- 1 file changed, 45 insertions(+), 55 deletions(-) diff --git a/components/PatternDisplay.tsx b/components/PatternDisplay.tsx index 50965e5..beb7d25 100644 --- a/components/PatternDisplay.tsx +++ b/components/PatternDisplay.tsx @@ -623,92 +623,79 @@ export const PatternDisplay: React.FC = ({ void main() { int id = gl_InstanceID; - int col = id % int(u_cols); // Track Index - int row = id / int(u_cols); // Step Index + // u_cols = numChannels; texture is stored as width=channels, height=steps + int trackIndex = id % int(u_cols); // 0 to numChannels-1 + int stepIndex = id / int(u_cols); // 0 to stepsForMode-1 - // 1. Check for Note Data - uint note = texelFetch(u_noteData, ivec2(col, row), 0).r; + // 1. Check for Note Data (texture: x=channel, y=step) + uint note = texelFetch(u_noteData, ivec2(trackIndex, stepIndex), 0).r; v_hasNote = (note > 0u) ? 1.0 : 0.0; - // 2. Calculate Cap Scale - // Scale is derived from u_cellSize with CAP_SCALE_FACTOR (0.88) for pixel-perfect fit - float capScale = min(u_cellSize.x, u_cellSize.y) * CAP_SCALE_FACTOR; - if (note == 0u) capScale = 0.0; // Hide empty steps - - // 3. Playhead Logic + // 2. Playhead Logic float stepsPerPage = (u_layoutMode == 3) ? 64.0 : 32.0; float relativePlayhead = mod(u_playhead, stepsPerPage); - float distToPlayhead = abs(float(row) - relativePlayhead); + float distToPlayhead = abs(float(stepIndex) - relativePlayhead); distToPlayhead = min(distToPlayhead, stepsPerPage - distToPlayhead); float activation = 1.0 - smoothstep(0.0, 1.5, distToPlayhead); - capScale *= 1.0 + (0.2 * activation); // Pop effect with smooth falloff v_active = activation; - // 4. Positioning Logic + // 3. Positioning Logic if (u_layoutMode == 2 || u_layoutMode == 3) { // --- HORIZONTAL LAYOUT (32-step or 64-step) --- - // Pixel-perfect centered caps using shared constants - // a_pos is in [-0.5, 0.5] range, we center it at [0.5, 0.5] within each cell - float i = float(row); // step index - float j = float(col); // track index - - // Calculate cell position - float cellX = u_offset.x + i * u_cellSize.x; - float cellY = u_offset.y + j * u_cellSize.y; - - // Center the cap within the cell using a_pos * capScale + cellCenter + // Steps run along X, channels run along Y + float capScale = min(u_cellSize.x, u_cellSize.y) * CAP_SCALE_FACTOR; + if (note == 0u) capScale = 0.0; + capScale *= 1.0 + (0.2 * activation); + + float cellX = u_offset.x + float(stepIndex) * u_cellSize.x; + float cellY = u_offset.y + float(trackIndex) * u_cellSize.y; + vec2 centered = a_pos * capScale + vec2(cellX + u_cellSize.x * 0.5, cellY + u_cellSize.y * 0.5); - - // Convert to NDC vec2 ndc = (centered / u_resolution) * 2.0 - 1.0; ndc.y = -ndc.y; gl_Position = vec4(ndc, 0.0, 1.0); } else { // --- CIRCULAR LAYOUT --- - // Use exact radii from shared constants: INNER_RADIUS=0.3, OUTER_RADIUS=0.9 - + // Use pixel-space radii (based on minDim) to match the WGSL background shader + // and prevent elliptical stretching on non-square viewports. float numTracks = u_cols; - - // Calculate track index with inversion support - float trackIndex = float(col); - if (u_invertChannels == 0) { trackIndex = numTracks - 1.0 - trackIndex; } - - // Normalized radius for this track (centered in track band) - float trackWidth = (OUTER_RADIUS - INNER_RADIUS) / numTracks; - float normalizedRadius = INNER_RADIUS + (trackIndex + 0.5) * trackWidth; - - // Full circle angle + float trackIndexF = float(trackIndex); + if (u_invertChannels == 0) { trackIndexF = numTracks - 1.0 - trackIndexF; } + + float minDim = min(u_resolution.x, u_resolution.y); + vec2 center = u_resolution * 0.5; + + // Mirror the WGSL: maxRadius = minDim*0.45, minRadius = minDim*0.15 + float maxRadius = minDim * 0.45; + float minRadius = minDim * 0.15; + float ringDepth = (maxRadius - minRadius) / numTracks; + float pixelRadius = minRadius + trackIndexF * ringDepth; + float totalSteps = 64.0; float anglePerStep = (2.0 * PI) / totalSteps; - float theta = -1.570796 + float(row) * anglePerStep; - - // Convert polar to cartesian in normalized space [0,1] - vec2 center = vec2(0.5, 0.5); - vec2 normPos = center + vec2(cos(theta), sin(theta)) * normalizedRadius * 0.5; - - // Calculate btnW and btnH from angular arc length and radial width - float arcLength = normalizedRadius * anglePerStep; + float theta = -1.570796 + float(stepIndex) * anglePerStep; + + // Pixel-space arc length → no aspect-ratio distortion + float arcLength = pixelRadius * anglePerStep; float btnW = arcLength * CAP_SCALE_FACTOR; - float btnH = trackWidth * 0.92; - - // Local position with rotation - vec2 localPos = a_pos * vec2(btnW, btnH); + float btnH = ringDepth * 0.92; + + float capScale = (note > 0u) ? (1.0 + 0.2 * activation) : 0.0; + vec2 localPos = a_pos * vec2(btnW, btnH) * capScale; + float rotAng = theta + 1.570796; float cA = cos(rotAng); float sA = sin(rotAng); float rotX = localPos.x * cA - localPos.y * sA; float rotY = localPos.x * sA + localPos.y * cA; - - // Map to pixel space for NDC conversion - vec2 pixelPos = normPos * u_resolution + vec2(rotX, rotY) * u_resolution; - + + vec2 pixelPos = center + vec2(cos(theta), sin(theta)) * pixelRadius + vec2(rotX, rotY); vec2 ndc = (pixelPos / u_resolution) * 2.0 - 1.0; ndc.y = -ndc.y; gl_Position = vec4(ndc, 0.0, 1.0); } - // Pass standard UV (0-1) for texture mapping v_uv = a_pos + 0.5; } `; @@ -1573,7 +1560,10 @@ export const PatternDisplay: React.FC = ({ try { const { program, vao, texture, uniforms } = res; - const cols = padTopChannel ? (matrix.numChannels || DEFAULT_CHANNELS) + 1 : (matrix.numChannels || DEFAULT_CHANNELS); + // ALWAYS match texture dimensions: width = numChannels, height = stepsForMode + // u_cols drives instance unrolling in the vertex shader (trackIndex = id % u_cols) + const numChannelsForGL = padTopChannel ? (matrix.numChannels || DEFAULT_CHANNELS) + 1 : (matrix.numChannels || DEFAULT_CHANNELS); + const cols = numChannelsForGL; const rows = matrix.numRows || DEFAULT_ROWS; // Check for GL errors before starting