diff --git a/dist/bezel-square.png b/dist/bezel-square.png index 6ffad3a..5014306 100644 Binary files a/dist/bezel-square.png and b/dist/bezel-square.png differ diff --git a/dist/bezel.orig.png b/dist/bezel.orig.png index 16cb352..70771b5 100644 Binary files a/dist/bezel.orig.png and b/dist/bezel.orig.png differ diff --git a/dist/bezel.png b/dist/bezel.png index 490181e..e43428a 100644 Binary files a/dist/bezel.png and b/dist/bezel.png differ diff --git a/dist/shaders/patternv0.42.wgsl b/dist/shaders/patternv0.42.wgsl index 178b346..908b6f0 100644 --- a/dist/shaders/patternv0.42.wgsl +++ b/dist/shaders/patternv0.42.wgsl @@ -1,69 +1,363 @@ +// patternv0.42.wgsl +// Circular Ring Layout — Single-pass composite +// - Channel = ring (innermost = ch0), Row = angular step (64 steps) +// - instanceIndex < numRows*numChannels → per-cell quads in ring arcs +// - instanceIndex == totalInstances → full-screen ring-grid background pass +// Note encoding: raw OpenMPT numeric values (0=empty, 1-120=notes, 121=OFF, 122=CUT, 123=FADE) + struct Uniforms { - numRows: u32, numChannels: u32, playheadRow: f32, isPlaying: u32, - cellW: f32, cellH: f32, canvasW: f32, canvasH: f32, tickOffset: f32, - bpm: f32, timeSec: f32, beatPhase: f32, groove: f32, kickTrigger: f32, - activeChannels: u32, isModuleLoaded: u32, bloomIntensity: f32, bloomThreshold: f32, - invertChannels: u32, dimFactor: f32, + numRows: u32, // [0] + numChannels: u32, // [1] + playheadRow: f32, // [2] float for smooth motion + isPlaying: u32, // [3] + cellW: f32, // [4] + cellH: f32, // [5] + canvasW: f32, // [6] + canvasH: f32, // [7] + tickOffset: f32, // [8] + bpm: f32, // [9] + timeSec: f32, // [10] + beatPhase: f32, // [11] + groove: f32, // [12] + kickTrigger: f32, // [13] + activeChannels: u32, // [14] + isModuleLoaded: u32, // [15] + bloomIntensity: f32, // [16] + bloomThreshold: f32, // [17] + invertChannels: u32, // [18] + dimFactor: f32, // [19] +}; + +@group(0) @binding(0) var cells: array; +@group(0) @binding(1) var uniforms: Uniforms; +@group(0) @binding(2) var rowFlags: array; + +struct ChannelState { + volume: f32, pan: f32, freq: f32, trigger: u32, + noteAge: f32, activeEffect: u32, effectValue: f32, isMuted: u32 }; -@group(0) @binding(1) var params: Uniforms; +@group(0) @binding(3) var channels: array; +@group(0) @binding(4) var buttonsSampler: sampler; +@group(0) @binding(5) var buttonsTexture: texture_2d; + +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(3) @interpolate(flat) packedA: u32, + @location(4) @interpolate(flat) packedB: u32, + @location(5) @interpolate(flat) isBackground: u32, +}; + +// Ring geometry constants +const TOTAL_STEPS: f32 = 64.0; +const TWO_PI: f32 = 6.2831853; +const HALF_PI: f32 = 1.5707963; + +@vertex +fn vs( + @builtin(vertex_index) vertexIndex: u32, + @builtin(instance_index) instanceIndex: u32 +) -> VertexOut { + var out: VertexOut; + let numChannels = uniforms.numChannels; + let totalCells = uniforms.numRows * numChannels; + + var quad = array, 6>( + vec2(0.0, 0.0), vec2(1.0, 0.0), vec2(0.0, 1.0), + vec2(0.0, 1.0), vec2(1.0, 0.0), vec2(1.0, 1.0) + ); + + // ── Background pass: render full-screen quad for ring-grid overlay ── + if (instanceIndex >= totalCells) { + var fs_pos = array, 6>( + vec2(-1.0,-1.0), vec2(1.0,-1.0), vec2(-1.0,1.0), + vec2(-1.0,1.0), vec2(1.0,-1.0), vec2(1.0, 1.0) + ); + out.position = vec4(fs_pos[vertexIndex], 0.0, 1.0); + out.uv = fs_pos[vertexIndex] * 0.5 + 0.5; + out.isBackground = 1u; + out.row = 0u; + out.channel = 0u; + out.packedA = 0u; + out.packedB = 0u; + return out; + } + + // ── Per-cell pass ── + let row = instanceIndex / numChannels; + let channel = instanceIndex % numChannels; + + var ringIndex = channel; + if (uniforms.invertChannels == 0u) { + ringIndex = numChannels - 1u - channel; + } + + 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 + ringDepth * 0.5; + + let anglePerStep = TWO_PI / TOTAL_STEPS; + let theta = -HALF_PI + f32(row % 64u) * anglePerStep; + + let circumference = TWO_PI * radius; + let arcLength = circumference / TOTAL_STEPS; + + let btnW = arcLength * 0.90; + let btnH = ringDepth * 0.88; + + let lp = quad[vertexIndex]; + let localPos = (lp - 0.5) * vec2(btnW, btnH); + + let rotAng = theta + HALF_PI; + let cA = cos(rotAng); + let sA = sin(rotAng); + + let rotX = localPos.x * cA - localPos.y * sA; + let rotY = localPos.x * sA + localPos.y * cA; + + let worldX = center.x + cos(theta) * radius + rotX; + let worldY = center.y + sin(theta) * radius + rotY; + + let clipX = (worldX / uniforms.canvasW) * 2.0 - 1.0; + let clipY = 1.0 - (worldY / uniforms.canvasH) * 2.0; + + let idx = instanceIndex * 2u; + var a = 0u; + var b = 0u; + if (idx + 1u < arrayLength(&cells)) { + a = cells[idx]; + b = cells[idx + 1u]; + } + + out.position = vec4(clipX, clipY, 0.0, 1.0); + out.row = row; + out.channel = channel; + out.uv = lp; + out.packedA = a; + out.packedB = b; + out.isBackground = 0u; + return out; +} + +// ── Colour helpers ────────────────────────────────────────────────────────── + +// Neon palette driven by beat phase +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 drift = uniforms.beatPhase * 0.08; + return a + b * cos(TWO_PI * (c * (t + drift) + d)); +} + +// Correct pitch class from raw OpenMPT note value (1–120) +// Returns 0.0–1.0 fraction around the colour wheel (C=0, C#=1/12 … B=11/12) +fn pitchClass(note: u32) -> f32 { + return f32((note - 1u) % 12u) / 12.0; +} -struct VertOut { @builtin(position) pos: vec4, @location(0) uv: vec2 }; -@vertex fn vs(@builtin(vertex_index) idx: u32) -> VertOut { - var v = array, 6>(vec2(-1.,-1.), vec2(1.,-1.), vec2(-1.,1.), vec2(-1.,1.), vec2(1.,-1.), vec2(1.,1.)); - var out: VertOut; out.pos = vec4(v[idx], 0.0, 1.0); out.uv = v[idx] * 0.5 + 0.5; return out; +fn sdRoundedBox(p: vec2, b: vec2, r: f32) -> f32 { + let q = abs(p) - b + r; + return length(max(q, vec2(0.0))) + min(max(q.x, q.y), 0.0) - r; } -@fragment fn fs(@location(0) uv: vec2) -> @location(0) vec4 { - let p = uv - 0.5; - let r = length(p) * 2.0; // 0..1 (Center to Edge) - let a = atan2(p.y, p.x); - - // Geometry Params matching WebGL Mode 1 - let maxRadius = 0.45 * 2.0; // 0.9 in shader space (since we mult r by 2) -> 0.9 - let minRadius = 0.15 * 2.0; // 0.3 +// ── Fragment ──────────────────────────────────────────────────────────────── + +@fragment +fn fs(in: VertexOut) -> @location(0) vec4 { + let dim = uniforms.dimFactor; + let bloom = uniforms.bloomIntensity; + + // ── Background ring-grid pass ────────────────────────────────────────── + if (in.isBackground == 1u) { + let uv = in.uv; // 0..1 + let p = uv - 0.5; // -0.5..0.5 + let r = length(p) * 2.0; + + let minDim = min(uniforms.canvasW, uniforms.canvasH); + let maxRadius = 0.45 * 2.0; + let minRadius = 0.15 * 2.0; - // Only draw in the track annulus if (r < minRadius || r > maxRadius) { return vec4(0.0); } - let numTracks = f32(params.numChannels); - - // Map radius to Track ID (0..N) - // r goes from 0.3 to 0.9 - let normR = (r - minRadius) / (maxRadius - minRadius); // 0..1 across band - let trackVal = normR * numTracks; - - // Track Dividers - let trackLine = 1.0 - smoothstep(0.0, 0.1, abs(fract(trackVal) - 0.5)); - - // Time Spoke Dividers (64 steps) - let steps = 64.0; - // Angle normalized - let angNorm = fract(a / (6.28318 / steps)); - let spokeLine = 1.0 - smoothstep(0.4, 0.5, abs(angNorm - 0.5)); - - var col = vec3(0.25, 0.28, 0.32); - - // Add structure - col += vec3(0.15) * trackLine; - col += vec3(0.05) * spokeLine; - - // Playhead Highlight (Ring) - let stepAngle = 6.28318 / steps; - let exactRow = params.playheadRow - floor(params.playheadRow / steps) * steps; - let currentAngle = -1.570796 + exactRow * stepAngle; - - let diff = abs(atan2(sin(a - currentAngle), cos(a - currentAngle))); - let highlight = 1.0 - smoothstep(0.0, stepAngle * 1.5, diff); - col += vec3(0.2, 0.4, 0.5) * highlight; - col += vec3(0.0, 0.25, 0.35) * exp(-max(0.0, diff - stepAngle) * 6.0); - - let pageProgress = fract(params.playheadRow / steps); - var boundaryFade = 1.0; - if (pageProgress < 0.05) { - boundaryFade = smoothstep(0.0, 0.05, pageProgress); - } else if (pageProgress > 0.95) { - boundaryFade = 1.0 - smoothstep(0.95, 1.0, pageProgress); + let numTracks = f32(uniforms.numChannels); + let normR = (r - minRadius) / (maxRadius - minRadius); + let trackVal = normR * numTracks; + + // Track ring dividers + let trackLine = 1.0 - smoothstep(0.0, 0.08, abs(fract(trackVal) - 0.5)); + + // Angular spoke dividers + let a = atan2(p.y, p.x); + let angNorm = fract(a / (TWO_PI / TOTAL_STEPS)); + let spokeLine = 1.0 - smoothstep(0.35, 0.5, abs(angNorm - 0.5)); + + var col = vec3(0.12, 0.13, 0.16); + col += vec3(0.10) * trackLine; + col += vec3(0.04) * spokeLine; + + // Playhead spoke highlight + let stepAngle = TWO_PI / TOTAL_STEPS; + let exactRow = uniforms.playheadRow - floor(uniforms.playheadRow / TOTAL_STEPS) * TOTAL_STEPS; + let currentAngle = -HALF_PI + exactRow * stepAngle; + let diff = abs(atan2(sin(a - currentAngle), cos(a - currentAngle))); + let highlight = 1.0 - smoothstep(0.0, stepAngle * 1.5, diff); + col += vec3(0.15, 0.35, 0.45) * highlight; + col += vec3(0.0, 0.20, 0.28) * exp(-max(0.0, diff - stepAngle) * 5.0); + + // Fade at page boundaries + let pageProgress = fract(uniforms.playheadRow / TOTAL_STEPS); + var fade = 1.0; + if (pageProgress < 0.05) { fade = smoothstep(0.0, 0.05, pageProgress); } + else if (pageProgress > 0.95) { fade = 1.0 - smoothstep(0.95, 1.0, pageProgress); } + + return vec4(col * fade * dim, 0.55); + } + + // ── Per-cell pass ────────────────────────────────────────────────────── + if (in.channel >= uniforms.numChannels) { discard; } + + let uv = in.uv; + let p = uv - 0.5; + + // Rounded-box clipping + let dBox = sdRoundedBox(p, vec2(0.44), 0.10); + if (dBox > 0.0) { discard; } + + // Unpack fields + let note = (in.packedA >> 24) & 255u; + let inst = (in.packedA >> 16) & 255u; + let volCmd = (in.packedA >> 8) & 255u; + // volVal = in.packedA & 255u (available if needed) + let effCmd = (in.packedB >> 8) & 255u; + + // Note classification (OpenMPT numeric encoding) + let hasNote = (note > 0u) && (note <= 120u); + let isNoteOff = (note == 121u); + let isNoteCut = (note == 122u) || (note == 123u); + let hasVol = (volCmd > 0u); + let hasEffect = (effCmd > 0u); + + // Playhead proximity + let playheadStep = uniforms.playheadRow - floor(uniforms.playheadRow / TOTAL_STEPS) * TOTAL_STEPS; + let rowInPage = f32(in.row % 64u); + let rowDistRaw = abs(rowInPage - playheadStep); + let rowDist = min(rowDistRaw, TOTAL_STEPS - rowDistRaw); + let onPlayhead = rowDist < 1.5; + let nearPlayhead = 1.0 - smoothstep(0.0, 2.0, rowDist); + + // Base plastic colour + var capColor = vec3(0.13, 0.14, 0.17); + var alpha = 1.0; + var glow = 0.0; + + // ── Channel state ───────────────────────────────────────────────────── + var chActive = false; + var chTrigger = false; + var chNoteAge = 0.0; + if (in.channel < arrayLength(&channels)) { + let ch = channels[in.channel]; + chNoteAge = ch.noteAge; + chTrigger = (ch.trigger > 0u) && onPlayhead; + chActive = (ch.volume > 0.01) && onPlayhead; + } + + // ── Render note data ───────────────────────────────────────────────── + if (hasNote) { + let pc = pitchClass(note); + let baseCol = neonPalette(pc); + capColor = mix(capColor, baseCol * 0.55, 0.6); + + // Playhead highlight + if (nearPlayhead > 0.0) { + glow = nearPlayhead * 0.8; + capColor = mix(capColor, baseCol, nearPlayhead * 0.65); + } + + // ── Blue note-on indicator (top-left micro-LED) ────────────────── + // Lights blue when the playhead is on this row and channel is active + let ledNoteOn = vec2(-0.30, -0.30); + let dLedOn = length(p - ledNoteOn) - 0.07; + if (dLedOn < 0.0) { + let ledCol = select( + vec3(0.05, 0.10, 0.20), // off — dark blue + vec3(0.25, 0.55, 1.00), // on — bright blue + chTrigger || (nearPlayhead > 0.5) + ); + let ledGlow = select(0.0, bloom * 1.2, chTrigger); + return vec4((ledCol + ledGlow) * dim, 1.0); + } + + // Trigger flash + if (chTrigger) { + glow += 1.2; + capColor = mix(capColor, baseCol * 1.4 + 0.3, 0.5); } + } else if (isNoteOff) { + // Dim red stripe for note-off + capColor = mix(capColor, vec3(0.45, 0.05, 0.05), 0.6); + alpha = 0.75; + } else if (isNoteCut) { + // Orange-red for note-cut/fade + capColor = mix(capColor, vec3(0.60, 0.20, 0.02), 0.5); + alpha = 0.75; + } + + // ── Amber volume/expression indicator (top-right micro-LED) ────────── + // Lights amber when a volume column command is present in this cell + let ledVol = vec2(0.30, -0.30); + let dLedVol = length(p - ledVol) - 0.07; + if (dLedVol < 0.0) { + let ledCol = select( + vec3(0.18, 0.12, 0.02), // off — dark amber + vec3(1.00, 0.65, 0.10), // on — bright amber + hasVol + ); + let ledGlow = select(0.0, bloom * 0.8, hasVol && onPlayhead); + return vec4((ledCol + ledGlow) * dim, 1.0); + } + + // ── Effect indicator (bottom centre micro-LED) ───────────────────── + let ledEff = vec2(0.0, 0.30); + let dLedEff = length(p - ledEff) - 0.06; + if (dLedEff < 0.0) { + let ledCol = select( + vec3(0.10, 0.14, 0.10), // off — dark green + vec3(0.30, 0.95, 0.40), // on — bright green + hasEffect + ); + return vec4(ledCol * dim, 1.0); + } + + // ── Frosted surface shading ────────────────────────────────────────── + let edge = smoothstep(0.0, 0.08, -dBox); + let nrm = normalize(vec3(p.x, p.y, 0.5)); + let light = normalize(vec3(0.5, -0.7, 1.0)); + let diffuse = max(0.0, dot(nrm, light)); + capColor *= (0.55 + 0.45 * diffuse); + capColor += vec3(glow * 0.4); + + // Playhead glow + if (onPlayhead) { + capColor += vec3(0.06, 0.08, 0.12) * nearPlayhead; + } + + if (glow > 0.0) { capColor *= (1.0 + bloom * 0.6); } + + // Kick pulse + let kickPulse = uniforms.kickTrigger * exp(-length(p) * 3.0) * 0.25; + capColor += vec3(0.9, 0.2, 0.4) * kickPulse * bloom; + + // Subtle dither + let noise = fract(sin(dot(uv * uniforms.timeSec, vec2(12.9898, 78.233))) * 43758.5453); + capColor += (noise - 0.5) * 0.008; - return vec4(col * boundaryFade, 0.6 * params.dimFactor); + return vec4(capColor * dim, edge * alpha); } diff --git a/dist/shaders/patternv0.45.wgsl b/dist/shaders/patternv0.45.wgsl index 8544356..fe52790 100644 --- a/dist/shaders/patternv0.45.wgsl +++ b/dist/shaders/patternv0.45.wgsl @@ -160,32 +160,13 @@ fn sdTriangle(p: vec2, r: f32) -> f32 { return -length(p2) * sign(p2.y); } -fn toUpperAscii(code: u32) -> u32 { - return select(code, code - 32u, (code >= 97u) & (code <= 122u)); -} - +// Correct pitch class from raw OpenMPT note value (1–120). +// OpenMPT: note 1 = C-0, note 2 = C#0 … note 12 = B-0, note 13 = C-1, … +// (note - 1) % 12 → 0=C, 1=C#, 2=D, 3=D#, 4=E, 5=F, 6=F#, 7=G, 8=G#, 9=A, 10=A#, 11=B fn pitchClassFromPacked(packed: u32) -> f32 { - let c0 = toUpperAscii((packed >> 24) & 255u); - var semitone: i32 = 0; - var valid = true; - switch (c0) { - case 65u: { semitone = 9; } - case 66u: { semitone = 11; } - case 67u: { semitone = 0; } - case 68u: { semitone = 2; } - case 69u: { semitone = 4; } - case 70u: { semitone = 5; } - case 71u: { semitone = 7; } - default: { valid = false; } - } - if (!valid) { return 0.0; } - let c1 = toUpperAscii((packed >> 16) & 255u); - if ((c1 == 35u) || (c1 == 43u)) { - semitone = (semitone + 1) % 12; - } else if (c1 == 66u) { - semitone = (semitone + 11) % 12; - } - return f32(semitone) / 12.0; + let note = (packed >> 24) & 255u; + if (note == 0u || note > 120u) { return 0.0; } + return f32((note - 1u) % 12u) / 12.0; } @fragment @@ -233,71 +214,114 @@ fn fs(in: VertexOut) -> @location(0) vec4 { return vec4(col * dimFactor, 1.0); } - // Frosted Cap Shape + // Frosted Cap Shape — use for alpha masking, not early discard let dBox = sdRoundedBox(uv - 0.5, vec2(0.42), 0.1); let isCap = dBox < 0.0; - - if (!isCap) { - discard; - } - + let capEdge = smoothstep(0.0, 0.08, -dBox); + var capColor = vec3(0.15, 0.16, 0.18); // Inactive plastic var glow = 0.0; - - let noteChar = (in.packedA >> 24) & 255u; - let hasNote = (noteChar >= 65u && noteChar <= 122u); // Simple check - - 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 p = uv - 0.5; + + // Unpack all fields (OpenMPT numeric encoding) + let note = (in.packedA >> 24) & 255u; + let volCmd = (in.packedA >> 8) & 255u; + let effCmd = (in.packedB >> 8) & 255u; + + let hasNote = (note > 0u) && (note <= 120u); // regular note + let isNoteOff = (note == 121u); // --- note-off + let isNoteCut = (note == 122u) || (note == 123u); // === note-cut/fade + let hasVol = (volCmd > 0u); + let hasEffect = (effCmd > 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 = rowDist < 1.5; + + // Channel live state + var chTrigger = false; + if (in.channel < arrayLength(&channels)) { + let ch = channels[in.channel]; + chTrigger = (ch.trigger > 0u) && onPlayhead; + } + if (hasNote) { let pitchHue = pitchClassFromPacked(in.packedA); - let baseCol = neonPalette(pitchHue); - capColor = mix(capColor, baseCol, 0.4); - - // Highlight if active row + let baseCol = neonPalette(pitchHue); + capColor = mix(capColor, baseCol * 0.55, 0.5); + if (playheadActivation > 0.0) { - glow = playheadActivation; - capColor = mix(capColor, vec3(1.0), 0.5); + glow = playheadActivation; + capColor = mix(capColor, baseCol, playheadActivation * 0.6); } - - // Trigger flash - let ch = channels[in.channel]; - if (ch.trigger > 0u && playheadActivation > 0.5) { - glow += 1.0; - capColor += vec3(0.5); + if (chTrigger) { + glow += 1.0; + capColor = mix(capColor, baseCol * 1.4 + 0.3, 0.5); } + + // ── Blue note-on indicator (top-left micro-LED) ────────────── + let ledNoteOn = vec2(-0.30, -0.30); + let dLedOn = length(p - ledNoteOn) - 0.07; + if (dLedOn < 0.0) { + let ledCol = select( + vec3(0.05, 0.10, 0.22), + vec3(0.25, 0.55, 1.00), + chTrigger || (playheadActivation > 0.5) + ); + return vec4(ledCol * dimFactor, 1.0); + } + } else if (isNoteOff) { + capColor = mix(capColor, vec3(0.45, 0.05, 0.05), 0.55); + } else if (isNoteCut) { + capColor = mix(capColor, vec3(0.60, 0.20, 0.02), 0.45); } - - // Playhead Highlight Line + + // ── Amber volume indicator (top-right micro-LED) ───────────────── + let ledVol = vec2(0.30, -0.30); + let dLedVol = length(p - ledVol) - 0.07; + if (dLedVol < 0.0) { + let ledCol = select( + vec3(0.18, 0.12, 0.02), + vec3(1.00, 0.65, 0.10), + hasVol + ); + let ledGlow = select(0.0, bloom * 0.8, hasVol && onPlayhead); + return vec4((ledCol + ledGlow) * dimFactor, 1.0); + } + + // ── Effect indicator (bottom-centre micro-LED) ──────────────────── + let ledEff = vec2(0.0, 0.30); + let dLedEff = length(p - ledEff) - 0.06; + if (dLedEff < 0.0) { + let ledCol = select( + vec3(0.08, 0.12, 0.08), + vec3(0.30, 0.95, 0.40), + hasEffect + ); + return vec4(ledCol * dimFactor, 1.0); + } + + // Playhead highlight if (playheadActivation > 0.0) { - capColor += vec3(0.1, 0.1, 0.15) * playheadActivation; - if (isPlaying && playheadActivation > 0.5) { - glow += 0.2; - } + capColor += vec3(0.08, 0.08, 0.12) * playheadActivation; + if (isPlaying && playheadActivation > 0.5) { glow += 0.2; } } - - // Frosted Effect - let edge = smoothstep(0.0, 0.1, -dBox); - let light = vec3(0.5, -0.8, 1.0); - let n = normalize(vec3((uv.x - 0.5), (uv.y - 0.5), 0.5)); - let diff = max(0.0, dot(n, normalize(light))); - + + // Frosted surface shading + let edge = smoothstep(0.0, 0.1, -dBox); + let nrm = normalize(vec3(p.x, p.y, 0.5)); + let light = normalize(vec3(0.5, -0.8, 1.0)); + let diff = max(0.0, dot(nrm, light)); capColor *= (0.5 + 0.5 * diff); capColor += vec3(glow * 0.5); - - // Bloom boost - if (glow > 0.0) { - capColor *= (1.0 + bloom); - } + if (glow > 0.0) { capColor *= (1.0 + bloom); } // Kick reactive glow - let p = in.uv - 0.5; let kickPulse = uniforms.kickTrigger * exp(-length(p) * 3.0) * 0.3; capColor += 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); + let noise = fract(sin(dot(uv * uniforms.timeSec, vec2(12.9898, 78.233))) * 43758.5453); capColor += (noise - 0.5) * 0.01; return vec4(capColor * dimFactor, edge); diff --git a/dist/shaders/patternv0.46.wgsl b/dist/shaders/patternv0.46.wgsl index 618ce7f..88cd37a 100644 --- a/dist/shaders/patternv0.46.wgsl +++ b/dist/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,257 @@ 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); + let p = uv - vec2(0.5, 0.5); + let aa = fwidth(p.y) * 0.33; 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; } + let fs = getFragmentConstants(); + let bloom = uniforms.bloomIntensity; // ── Playhead proximity ──────────────────────────────────────────────────── - let totalSteps = 64.0; + 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); + 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); + + // NOTE: Early discard moved to after derivative computation to avoid undefined behavior in fwidth() + // Clip UI strip at bottom of canvas — SAFE HERE after derivatives computed + if (in.position.y > uniforms.canvasH * 0.88) { discard; } // ── Trailing sweep ──────────────────────────────────────────────────────── let stepsBehind = fract((playheadStep - rowF) / totalSteps) * totalSteps; - let trailGlow = select( + let trailGlow = select( 0.0, exp(-stepsBehind * 0.40), stepsBehind > 0.001 && stepsBehind < 14.0 ); - // ── 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.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)); } - // 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); + 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; } - // ── 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); + 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/dist/shaders/patternv0.47.wgsl b/dist/shaders/patternv0.47.wgsl index a548ea6..d05fd1c 100644 --- a/dist/shaders/patternv0.47.wgsl +++ b/dist/shaders/patternv0.47.wgsl @@ -354,13 +354,19 @@ fn fs(in: VertexOut) -> @location(0) vec4 { if (inButton > 0.5) { let noteChar = (in.packedA >> 24) & 255u; - let inst = in.packedA & 255u; + let inst = (in.packedA >> 16) & 255u; // Fixed: bits 16-23, not 0-7 let effCode = (in.packedB >> 8) & 255u; let effParam = in.packedB & 255u; - let hasNote = (noteChar >= 65u && noteChar <= 71u); + // Fixed: numeric note values 1-120, not ASCII A-G (65-71) + let hasNote = (noteChar > 0u) && (noteChar <= 120u); let hasEffect = (effParam > 0u); - let ch = channels[in.channel]; + + // Bounds check for channel state array access + var ch = ChannelState(0.0, 0.0, 0.0, 0u, 1000.0, 0u, 0.0, 0u); + if (in.channel < arrayLength(&channels)) { + ch = channels[in.channel]; + } let isMuted = (ch.isMuted == 1u); // COMPONENT 1: ACTIVITY LIGHT (Blue indicator for trap) diff --git a/dist/shaders/patternv0.48.wgsl b/dist/shaders/patternv0.48.wgsl index 3a252e3..6658072 100644 --- a/dist/shaders/patternv0.48.wgsl +++ b/dist/shaders/patternv0.48.wgsl @@ -1,6 +1,6 @@ // patternv0.48.wgsl -// Trap Frosted Disc - Improved Translucency with Visible Diode Shape -// Circular Layout with Translucent Glass Caps + Blue/Orange Lighting +// Three-Emitter LED Indicator System - Blue Note-On, Steady Note Color, Amber Control +// Circular Layout with Unified Glass Cap Lens // Based on v0.36 (disc layout with direct Note/Instr/Vol/Effect integer data) // PackedA: [Note(8) | Instr(8) | VolCmd(8) | VolVal(8)] // PackedB: [Unused(16) | EffCmd(8) | EffVal(8)] @@ -151,121 +151,119 @@ fn getFragmentConstants() -> FragmentConstants { return c; } -// --- EMITTER DIODE SHAPE --- -// Draws the actual LED emitter that shows through the glass cap -fn drawEmitterDiode(uv: vec2, intensity: f32, color: vec3, isOn: bool) -> vec4 { - // Diode is a small rounded rectangle positioned at the bottom of the cap - let diodeSize = vec2(0.35, 0.18); - let diodePos = vec2(0.0, 0.22); // Positioned toward bottom - - let p = uv - diodePos; - let dDiode = sdRoundedBox(p, diodeSize * 0.5, 0.08); - - // Diode has a smaller "die" inside it - let dieSize = vec2(0.18, 0.09); - let dDie = sdRoundedBox(p, dieSize * 0.5, 0.04); - - // Base diode housing (darker) - let diodeMask = 1.0 - smoothstep(0.0, 0.02, dDiode); - let dieMask = 1.0 - smoothstep(0.0, 0.01, dDie); - - var diodeColor = vec3(0.08, 0.08, 0.1); // Dark housing - - if (isOn) { - // The die glows with the note color - let dieGlow = color * (1.0 + intensity * 3.0); - let housingGlow = color * 0.15 * intensity; +// --- UNIFIED THREE-EMITTER GLASS LENS --- +// Draws a glass cap that covers all three emitters (blue, note, amber) +// The lens refracts and diffuses light from the three sources below +fn drawThreeEmitterLens( + uv: vec2, + size: vec2, + topColor: vec3, + topIntensity: f32, + midColor: vec3, + midIntensity: f32, + botColor: vec3, + botIntensity: f32, + aa: f32 +) -> vec4 { + let p = uv; + let dBox = sdRoundedBox(p, size * 0.5, 0.08); + + if (dBox > 0.0) { + return vec4(0.0); + } - diodeColor = mix(housingGlow, dieGlow, dieMask); + // Emitter positions within the lens + let topPos = vec2(0.0, -0.28); // Top: Blue note-on + let midPos = vec2(0.0, 0.0); // Middle: Note color + let botPos = vec2(0.0, 0.28); // Bottom: Amber control - // Add a hotspot in the center of the die - let hotspot = exp(-length(p / vec2(0.08, 0.04)) * 2.0) * intensity; - diodeColor += color * hotspot * 0.5; - } + // Calculate distance to each emitter for light propagation + let distTop = length(uv - topPos); + let distMid = length(uv - midPos); + let distBot = length(uv - botPos); - return vec4(diodeColor, diodeMask); -} + // Glass surface normal for reflections + let n = normalize(vec3(p.x * 2.0 / size.x, p.y * 2.0 / size.y, 0.4)); + let viewDir = vec3(0.0, 0.0, 1.0); + let fresnel = pow(1.0 - abs(dot(n, viewDir)), 2.0); + let radial = length(p / (size * 0.5)); -// --- ENHANCED TRANSLUCENT FROSTED GLASS CAP --- -// Shows the diode shape underneath with proper translucency -fn drawFrostedGlassCap(uv: vec2, size: vec2, color: vec3, isOn: bool, aa: f32, noteGlow: f32, diodeColor: vec3, diodeIntensity: f32) -> vec4 { - let p = uv; - let dBox = sdRoundedBox(p, size * 0.5, 0.08); + // Glass thickness varies - thicker at edges + let edgeThickness = 0.15 + radial * 0.08; + let centerThickness = 0.08; + let thickness = mix(centerThickness, edgeThickness, radial); - if (dBox > 0.0) { - return vec4(0.0); - } + // Background + let bgColor = vec3(0.04, 0.04, 0.05); - // Draw the emitter diode underneath - let diode = drawEmitterDiode(uv, diodeIntensity, diodeColor, isOn); + // Calculate light contribution from each emitter + // Light diffuses outward from each emitter position - // Glass surface normal for reflections - let n = normalize(vec3(p.x * 2.0 / size.x, p.y * 2.0 / size.y, 0.4)); - let viewDir = vec3(0.0, 0.0, 1.0); - let fresnel = pow(1.0 - abs(dot(n, viewDir)), 2.0); - let radial = length(p / (size * 0.5)); + // Top emitter (Blue) - diffuses downward + let topGlow = exp(-distTop * 5.0) * topIntensity; + let topDownwardBias = smoothstep(0.0, 0.4, uv.y - topPos.y); + let topContribution = topGlow * topDownwardBias * topColor; - // Glass thickness varies - thicker at edges - let edgeThickness = 0.15 + radial * 0.08; - let centerThickness = 0.08; - let thickness = mix(centerThickness, edgeThickness, radial); + // Middle emitter (Note color) - diffuses uniformly + let midGlow = exp(-distMid * 4.0) * midIntensity; + let midContribution = midGlow * midColor; - // Subsurface scattering - light travels through the glass - // Illumination is concentrated above the emitter (negative Y is up in UV space) - let emitterPos = vec2(0.0, 0.22); - let distFromEmitter = length(uv - emitterPos); - let lightTravel = exp(-distFromEmitter * 4.0) * noteGlow; + // Bottom emitter (Amber) - diffuses upward + let botGlow = exp(-distBot * 5.0) * botIntensity; + let botUpwardBias = smoothstep(0.0, 0.4, botPos.y - uv.y); + let botContribution = botGlow * botUpwardBias * botColor; - // Light concentrates upward from the emitter - let upwardBias = smoothstep(0.0, -0.3, uv.y - emitterPos.y); - let subsurface = lightTravel * upwardBias * (1.0 - radial * 0.3); + // Combine all light contributions + var totalLight = vec3(0.0); + totalLight += topContribution * 2.5; + totalLight += midContribution * 3.0; + totalLight += botContribution * 2.5; - let bgColor = vec3(0.04, 0.04, 0.05); + // Glass tint varies with light passing through + var litTint = vec3(0.95, 0.95, 1.0); + if (topIntensity > 0.0) { litTint = mix(litTint, topColor, topIntensity * 0.25); } + if (midIntensity > 0.0) { litTint = mix(litTint, midColor, midIntensity * 0.3); } + if (botIntensity > 0.0) { litTint = mix(litTint, botColor, botIntensity * 0.25); } - // Glass tint varies with light passing through - let litTint = mix(vec3(0.95, 0.95, 1.0), color, noteGlow * 0.3); - let glassBaseColor = mix(bgColor * 0.15, litTint, 0.85); + let glassBaseColor = mix(bgColor * 0.15, litTint, 0.85); - // Edge alpha with anti-aliasing - let edgeAlpha = smoothstep(0.0, aa * 2.0, -dBox); + // Edge alpha with anti-aliasing + let edgeAlpha = smoothstep(0.0, aa * 2.0, -dBox); - // Glass is more transparent where the diode is visible - let diodeVisibility = diode.a * 0.6; // Diode shows through at 60% - let baseAlpha = 0.75 + 0.25 * fresnel; - let alpha = mix(baseAlpha, 0.35, diodeVisibility) * edgeAlpha; + // Glass alpha varies with emitter intensity (brighter = more transparent) + let totalIntensity = topIntensity + midIntensity + botIntensity; + let baseAlpha = 0.75 + 0.25 * fresnel; + let alpha = mix(baseAlpha, 0.45, totalIntensity * 0.5) * edgeAlpha; - // Directional lighting from top-left - let lightDir = vec3(0.4, -0.7, 0.6); - let diff = max(0.0, dot(n, normalize(lightDir))); - let spec = pow(max(0.0, dot(reflect(-normalize(lightDir), n), viewDir)), 32.0); + // Directional lighting from top-left + let lightDir = vec3(0.4, -0.7, 0.6); + let diff = max(0.0, dot(n, normalize(lightDir))); + let spec = pow(max(0.0, dot(reflect(-normalize(lightDir), n), viewDir)), 32.0); - let litGlassColor = glassBaseColor * (0.5 + 0.5 * diff) + vec3(spec * 0.3); + let litGlassColor = glassBaseColor * (0.5 + 0.5 * diff) + vec3(spec * 0.3); - // Start with background - var finalColor = bgColor; + // Start with background + var finalColor = bgColor; - // Layer the diode underneath - let diodeBlend = diode.a * (1.0 - alpha * 0.7); // Diode visible through glass - finalColor = mix(finalColor, diode.rgb, diodeBlend); + // Apply the combined light through the glass + finalColor += totalLight * 0.8; - // Apply glass layer - finalColor = mix(finalColor, litGlassColor, alpha); + // Apply glass layer + finalColor = mix(finalColor, litGlassColor, alpha); - // Add subsurface glow from light passing through - finalColor += subsurface * color * 2.5; + // Add emitter hot spots where the actual LEDs are + let topHotspot = exp(-distTop * 12.0) * topIntensity; + let midHotspot = exp(-distMid * 10.0) * midIntensity; + let botHotspot = exp(-distBot * 12.0) * botIntensity; - // Inner glow when on - concentrated above the diode - if (isOn) { - let glowCenter = vec2(0.0, -0.1); // Above the diode - let glowDist = length(uv - glowCenter); - let innerGlow = (1.0 - smoothstep(0.0, 0.4, glowDist)) * noteGlow * 0.35; - finalColor += color * innerGlow; - } + finalColor += topColor * topHotspot * 1.5; + finalColor += midColor * midHotspot * 1.2; + finalColor += botColor * botHotspot * 1.5; - // Fresnel rim highlight - finalColor += fresnel * vec3(0.9, 0.95, 1.0) * 0.15; + // Fresnel rim highlight + finalColor += fresnel * vec3(0.9, 0.95, 1.0) * 0.15; - return vec4(finalColor, edgeAlpha); + return vec4(finalColor, edgeAlpha); } @fragment @@ -274,7 +272,7 @@ fn fs(in: VertexOut) -> @location(0) vec4 { let uv = in.uv; let p = uv - 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; @@ -291,10 +289,13 @@ fn fs(in: VertexOut) -> @location(0) vec4 { if (in.channel == 0u) { let onPlayhead = playheadActivation > 0.5; let indSize = vec2(0.3, 0.3); + // Simple indicator - no three-emitter here, just status let indColor = mix(vec3(0.15), fs.ledOnColor * 1.3, playheadActivation); - let indLed = drawFrostedGlassCap(p, indSize, indColor, onPlayhead, aa, playheadActivation * 1.5, indColor, playheadActivation); - var col = indLed.rgb; - var alpha = indLed.a; + // Use basic glass cap for indicator + let dBox = sdRoundedBox(p, indSize * 0.5, 0.08); + let edgeAlpha = smoothstep(0.0, aa * 2.0, -dBox); + var col = mix(vec3(0.04), indColor, playheadActivation * 0.8); + var alpha = edgeAlpha; if (playheadActivation > 0.0) { let beatPulse = 1.0 + kick * 0.6 + (0.5 + 0.5 * sin(beat * 6.2832)) * 0.2; let glow = fs.ledOnColor * (bloom * 5.0) * exp(-length(p) * 3.5) * playheadActivation * beatPulse; @@ -329,81 +330,73 @@ fn fs(in: VertexOut) -> @location(0) vec4 { let hasNote = (note > 0u); let hasExpression = (volCmd > 0u) || (effCmd > 0u); - let ch = channels[in.channel]; + // Bounds check for channel state array access + var ch = ChannelState(0.0, 0.0, 0.0, 0u, 1000.0, 0u, 0.0, 0u); + if (in.channel < arrayLength(&channels)) { + ch = channels[in.channel]; + } let isMuted = (ch.isMuted == 1u); - // COMPONENT 1: DATA LIGHT (Blue accent for trap) - let topUV = btnUV - vec2(0.5, 0.16); - let topSize = vec2(0.20, 0.20); - let isDataPresent = hasExpression && !isMuted; - let topColorBase = vec3(0.15, 0.5, 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), topColor, select(0.0, 1.0, isDataPresent)); - finalColor = mix(finalColor, topLed.rgb, topLed.a); - - // COMPONENT 2: NOTE LIGHT - let mainUV = btnUV - vec2(0.5, 0.5); - let mainSize = vec2(0.55, 0.45); - var noteColor = vec3(0.15); - var lightAmount = 0.0; - var noteGlow = 0.0; + // --- THREE-EMITTER SYSTEM --- + + // EMITTER 1 (TOP): Blue Note-On Indicator + // Lights up when note is triggered or playhead is on this step + let blueColor = vec3(0.15, 0.5, 1.0); + var topIntensity = 0.0; + if (!isMuted) { + if (ch.trigger > 0u) { + topIntensity = 1.0 + bloom; + } else if (playheadActivation > 0.5) { + topIntensity = playheadActivation * 0.6; + } + } + // EMITTER 2 (MIDDLE): Steady Note Color + // Shows pitch color whenever there's a note + var midColor = vec3(0.15); + var midIntensity = 0.12; // Base dim glow 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 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); - - // Beat-reactive: brighter pulse on kick - let beatBoost = 1.0 + kick * 0.5; - lightAmount = (activeVal * 0.9 + flash + strike + (linger * 2.5)) * clamp(ch.volume, 0.0, 1.2) * beatBoost; - 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, displayColor, noteGlow); - finalColor = mix(finalColor, mainPad.rgb, mainPad.a); + midColor = baseColor * instBright; - // Playhead pulse - if (playheadActivation > 0.5 && hasNote) { - let pulseColor = mix(vec3(0.15, 0.5, 1.0), vec3(1.0, 0.55, 0.1), 0.5 + 0.5 * sin(beat * 6.2832)); - finalColor += pulseColor * playheadActivation * 0.15; + // Steady indication - doesn't blink, just shows note presence + midIntensity = 0.6 + bloom * 2.0; + if (isMuted) { midIntensity *= 0.3; } } - // COMPONENT 3: EFFECT INDICATOR (Orange pill) - let botUV = btnUV - vec2(0.5, 0.85); - let botSize = vec2(0.25, 0.12); - var effColor = vec3(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(1.0, 0.55, 0.1); - if (!isMuted) { effColor *= 0.6; isEffOn = true; } + // EMITTER 3 (BOTTOM): Amber Control Message Indicator + // Lights up when there's an effect or volume command + let amberColor = vec3(1.0, 0.55, 0.1); + var botIntensity = 0.0; + if (!isMuted && hasExpression) { + botIntensity = 0.8 + bloom; } - let botLed = drawFrostedGlassCap(botUV, botSize, effColor, isEffOn, aa, select(0.0, 0.7, isEffOn), effColor, select(0.0, 0.7, isEffOn)); - finalColor = mix(finalColor, botLed.rgb, botLed.a); + // --- RENDER UNIFIED GLASS LENS --- + let lensUV = btnUV - vec2(0.5, 0.5); + let lensSize = vec2(0.55, 0.75); + + let lens = drawThreeEmitterLens( + lensUV, lensSize, + blueColor, topIntensity, + midColor, midIntensity, + amberColor, botIntensity, + aa + ); + + finalColor = mix(finalColor, lens.rgb, lens.a); + + // Add external glow when active + if (topIntensity > 0.0 || botIntensity > 0.0 || midIntensity > 0.5) { + let totalActivity = topIntensity + botIntensity + (midIntensity - 0.12); + let glowColor = mix(midColor, blueColor, topIntensity * 0.5); + let glowColor2 = mix(glowColor, amberColor, botIntensity * 0.5); + let externalGlow = glowColor2 * totalActivity * bloom * 2.0 * exp(-length(p) * 4.0); + finalColor += externalGlow; + } } // Kick reactive glow diff --git a/dist/shaders/patternv0.49.wgsl b/dist/shaders/patternv0.49.wgsl index 2aae1cf..bc2e9ef 100644 --- a/dist/shaders/patternv0.49.wgsl +++ b/dist/shaders/patternv0.49.wgsl @@ -1,6 +1,6 @@ // patternv0.49.wgsl -// Trap Frosted Glass - Improved Translucency with Visible Diode Shape -// Circular Layout + Integrated UI + Enhanced Glass Caps with Diode Visibility +// Three-Emitter LED Indicator System - Blue Note-On, Steady Note Color, Amber Control +// Circular Layout with Unified Glass Cap Lens // Based on v0.38 (circular layout with padTopChannel=true) // Note: Requires padTopChannel=true in PatternDisplay to shift music channels 1-32. @@ -155,121 +155,119 @@ fn getFragmentConstants() -> FragmentConstants { return c; } -// --- EMITTER DIODE SHAPE --- -// Draws the actual LED emitter that shows through the glass cap -fn drawEmitterDiode(uv: vec2, intensity: f32, color: vec3, isOn: bool) -> vec4 { - // Diode is a small rounded rectangle positioned at the bottom of the cap - let diodeSize = vec2(0.35, 0.18); - let diodePos = vec2(0.0, 0.22); // Positioned toward bottom - - let p = uv - diodePos; - let dDiode = sdRoundedBox(p, diodeSize * 0.5, 0.08); - - // Diode has a smaller "die" inside it - let dieSize = vec2(0.18, 0.09); - let dDie = sdRoundedBox(p, dieSize * 0.5, 0.04); - - // Base diode housing (darker) - let diodeMask = 1.0 - smoothstep(0.0, 0.02, dDiode); - let dieMask = 1.0 - smoothstep(0.0, 0.01, dDie); +// --- UNIFIED THREE-EMITTER GLASS LENS --- +// Draws a glass cap that covers all three emitters (blue, note, amber) +// The lens refracts and diffuses light from the three sources below +fn drawThreeEmitterLens( + uv: vec2, + size: vec2, + topColor: vec3, + topIntensity: f32, + midColor: vec3, + midIntensity: f32, + botColor: vec3, + botIntensity: f32, + aa: f32 +) -> vec4 { + let p = uv; + let dBox = sdRoundedBox(p, size * 0.5, 0.08); + + if (dBox > 0.0) { + return vec4(0.0); + } - var diodeColor = vec3(0.08, 0.08, 0.1); // Dark housing + // Emitter positions within the lens + let topPos = vec2(0.0, -0.28); // Top: Blue note-on + let midPos = vec2(0.0, 0.0); // Middle: Note color + let botPos = vec2(0.0, 0.28); // Bottom: Amber control - if (isOn) { - // The die glows with the note color - let dieGlow = color * (1.0 + intensity * 3.0); - let housingGlow = color * 0.15 * intensity; + // Calculate distance to each emitter for light propagation + let distTop = length(uv - topPos); + let distMid = length(uv - midPos); + let distBot = length(uv - botPos); - diodeColor = mix(housingGlow, dieGlow, dieMask); + // Glass surface normal for reflections + let n = normalize(vec3(p.x * 2.0 / size.x, p.y * 2.0 / size.y, 0.4)); + let viewDir = vec3(0.0, 0.0, 1.0); + let fresnel = pow(1.0 - abs(dot(n, viewDir)), 2.0); + let radial = length(p / (size * 0.5)); - // Add a hotspot in the center of the die - let hotspot = exp(-length(p / vec2(0.08, 0.04)) * 2.0) * intensity; - diodeColor += color * hotspot * 0.5; - } + // Glass thickness varies - thicker at edges + let edgeThickness = 0.15 + radial * 0.08; + let centerThickness = 0.08; + let thickness = mix(centerThickness, edgeThickness, radial); - return vec4(diodeColor, diodeMask); -} + // Background + let bgColor = vec3(0.04, 0.04, 0.05); -// --- ENHANCED TRANSLUCENT FROSTED GLASS CAP --- -// Shows the diode shape underneath with proper translucency -fn drawFrostedGlassCap(uv: vec2, size: vec2, color: vec3, isOn: bool, aa: f32, noteGlow: f32, diodeColor: vec3, diodeIntensity: f32) -> vec4 { - let p = uv; - let dBox = sdRoundedBox(p, size * 0.5, 0.08); + // Calculate light contribution from each emitter + // Light diffuses outward from each emitter position - if (dBox > 0.0) { - return vec4(0.0); - } + // Top emitter (Blue) - diffuses downward + let topGlow = exp(-distTop * 5.0) * topIntensity; + let topDownwardBias = smoothstep(0.0, 0.4, uv.y - topPos.y); + let topContribution = topGlow * topDownwardBias * topColor; - // Draw the emitter diode underneath - let diode = drawEmitterDiode(uv, diodeIntensity, diodeColor, isOn); + // Middle emitter (Note color) - diffuses uniformly + let midGlow = exp(-distMid * 4.0) * midIntensity; + let midContribution = midGlow * midColor; - // Glass surface normal for reflections - let n = normalize(vec3(p.x * 2.0 / size.x, p.y * 2.0 / size.y, 0.4)); - let viewDir = vec3(0.0, 0.0, 1.0); - let fresnel = pow(1.0 - abs(dot(n, viewDir)), 2.0); - let radial = length(p / (size * 0.5)); + // Bottom emitter (Amber) - diffuses upward + let botGlow = exp(-distBot * 5.0) * botIntensity; + let botUpwardBias = smoothstep(0.0, 0.4, botPos.y - uv.y); + let botContribution = botGlow * botUpwardBias * botColor; - // Glass thickness varies - thicker at edges - let edgeThickness = 0.15 + radial * 0.08; - let centerThickness = 0.08; - let thickness = mix(centerThickness, edgeThickness, radial); + // Combine all light contributions + var totalLight = vec3(0.0); + totalLight += topContribution * 2.5; + totalLight += midContribution * 3.0; + totalLight += botContribution * 2.5; - // Subsurface scattering - light travels through the glass - // Illumination is concentrated above the emitter (negative Y is up in UV space) - let emitterPos = vec2(0.0, 0.22); - let distFromEmitter = length(uv - emitterPos); - let lightTravel = exp(-distFromEmitter * 4.0) * noteGlow; + // Glass tint varies with light passing through + var litTint = vec3(0.95, 0.95, 1.0); + if (topIntensity > 0.0) { litTint = mix(litTint, topColor, topIntensity * 0.25); } + if (midIntensity > 0.0) { litTint = mix(litTint, midColor, midIntensity * 0.3); } + if (botIntensity > 0.0) { litTint = mix(litTint, botColor, botIntensity * 0.25); } - // Light concentrates upward from the emitter - let upwardBias = smoothstep(0.0, -0.3, uv.y - emitterPos.y); - let subsurface = lightTravel * upwardBias * (1.0 - radial * 0.3); + let glassBaseColor = mix(bgColor * 0.15, litTint, 0.85); - let bgColor = vec3(0.04, 0.04, 0.05); + // Edge alpha with anti-aliasing + let edgeAlpha = smoothstep(0.0, aa * 2.0, -dBox); - // Glass tint varies with light passing through - let litTint = mix(vec3(0.95, 0.95, 1.0), color, noteGlow * 0.3); - let glassBaseColor = mix(bgColor * 0.15, litTint, 0.85); + // Glass alpha varies with emitter intensity (brighter = more transparent) + let totalIntensity = topIntensity + midIntensity + botIntensity; + let baseAlpha = 0.75 + 0.25 * fresnel; + let alpha = mix(baseAlpha, 0.45, totalIntensity * 0.5) * edgeAlpha; - // Edge alpha with anti-aliasing - let edgeAlpha = smoothstep(0.0, aa * 2.0, -dBox); + // Directional lighting from top-left + let lightDir = vec3(0.4, -0.7, 0.6); + let diff = max(0.0, dot(n, normalize(lightDir))); + let spec = pow(max(0.0, dot(reflect(-normalize(lightDir), n), viewDir)), 32.0); - // Glass is more transparent where the diode is visible - let diodeVisibility = diode.a * 0.6; // Diode shows through at 60% - let baseAlpha = 0.75 + 0.25 * fresnel; - let alpha = mix(baseAlpha, 0.35, diodeVisibility) * edgeAlpha; + let litGlassColor = glassBaseColor * (0.5 + 0.5 * diff) + vec3(spec * 0.3); - // Directional lighting from top-left - let lightDir = vec3(0.4, -0.7, 0.6); - let diff = max(0.0, dot(n, normalize(lightDir))); - let spec = pow(max(0.0, dot(reflect(-normalize(lightDir), n), viewDir)), 32.0); + // Start with background + var finalColor = bgColor; - let litGlassColor = glassBaseColor * (0.5 + 0.5 * diff) + vec3(spec * 0.3); + // Apply the combined light through the glass + finalColor += totalLight * 0.8; - // Start with background - var finalColor = bgColor; + // Apply glass layer + finalColor = mix(finalColor, litGlassColor, alpha); - // Layer the diode underneath - let diodeBlend = diode.a * (1.0 - alpha * 0.7); // Diode visible through glass - finalColor = mix(finalColor, diode.rgb, diodeBlend); + // Add emitter hot spots where the actual LEDs are + let topHotspot = exp(-distTop * 12.0) * topIntensity; + let midHotspot = exp(-distMid * 10.0) * midIntensity; + let botHotspot = exp(-distBot * 12.0) * botIntensity; - // Apply glass layer - finalColor = mix(finalColor, litGlassColor, alpha); + finalColor += topColor * topHotspot * 1.5; + finalColor += midColor * midHotspot * 1.2; + finalColor += botColor * botHotspot * 1.5; - // Add subsurface glow from light passing through - finalColor += subsurface * color * 2.5; + // Fresnel rim highlight + finalColor += fresnel * vec3(0.9, 0.95, 1.0) * 0.15; - // Inner glow when on - concentrated above the diode - if (isOn) { - let glowCenter = vec2(0.0, -0.1); // Above the diode - let glowDist = length(uv - glowCenter); - let innerGlow = (1.0 - smoothstep(0.0, 0.4, glowDist)) * noteGlow * 0.35; - finalColor += color * innerGlow; - } - - // Fresnel rim highlight - finalColor += fresnel * vec3(0.9, 0.95, 1.0) * 0.15; - - return vec4(finalColor, edgeAlpha); + return vec4(finalColor, edgeAlpha); } @fragment @@ -285,26 +283,28 @@ fn fs(in: VertexOut) -> @location(0) vec4 { let kick = uniforms.kickTrigger; let beat = uniforms.beatPhase; - // Hardware Layering: Discard pixels over UI - if (in.position.y > uniforms.canvasH * 0.88) { - discard; - } - - // Smooth playhead position + // Smooth playhead position — compute BEFORE any early discard 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); + // Hardware Layering: Discard pixels over UI — SAFE HERE after playheadActivation computed + if (in.position.y > uniforms.canvasH * 0.88) { + discard; + } + // CHANNEL 0 is the Indicator Ring (padTopChannel shifts music to 1-32) if (in.channel == 0u) { let onPlayhead = playheadActivation > 0.5; let indSize = vec2(0.3, 0.3); + // Simple indicator - no three-emitter here, just status let indColor = mix(vec3(0.15), fs.ledOnColor * 1.3, playheadActivation); - // For indicator, use simpler cap without diode - let indLed = drawFrostedGlassCap(p, indSize, indColor, onPlayhead, aa, playheadActivation * 1.5, indColor, playheadActivation); - var col = indLed.rgb; - var alpha = indLed.a; + // Use basic glass cap for indicator + let dBox = sdRoundedBox(p, indSize * 0.5, 0.08); + let edgeAlpha = smoothstep(0.0, aa * 2.0, -dBox); + var col = mix(vec3(0.04), indColor, playheadActivation * 0.8); + var alpha = edgeAlpha; if (playheadActivation > 0.0) { let beatPulse = 1.0 + kick * 0.6 + (0.5 + 0.5 * sin(beat * 6.2832)) * 0.2; let glow = fs.ledOnColor * (bloom * 5.0) * exp(-length(p) * 3.5) * playheadActivation * beatPulse; @@ -314,7 +314,7 @@ fn fs(in: VertexOut) -> @location(0) vec4 { return vec4(col, clamp(alpha, 0.0, 1.0)); } - // --- MUSIC CHANNELS (1-32) with TRANSLUCENT GLASS --- + // --- MUSIC CHANNELS (1-32) with THREE-EMITTER LED SYSTEM --- let dHousing = sdRoundedBox(p, fs.housingSize * 0.5, 0.06); let housingMask = 1.0 - smoothstep(0.0, aa * 1.5, dHousing); @@ -331,88 +331,81 @@ fn fs(in: VertexOut) -> @location(0) vec4 { let note = (in.packedA >> 24) & 255u; let inst = (in.packedA >> 16) & 255u; let volCmd = (in.packedA >> 8) & 255u; + let volVal = in.packedA & 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]; + + // Bounds check for channel state array access + var ch = ChannelState(0.0, 0.0, 0.0, 0u, 1000.0, 0u, 0.0, 0u); + if (in.channel < arrayLength(&channels)) { + ch = channels[in.channel]; + } let isMuted = (ch.isMuted == 1u); - // Top LED indicator (Blue accent) - let topUV = btnUV - vec2(0.5, 0.16); - let topSize = vec2(0.20, 0.20); - let isDataPresent = hasExpression && !isMuted; - let topColorBase = vec3(0.15, 0.5, 1.0); - let topColor = topColorBase * select(0.0, 1.5 + bloom, isDataPresent); - // Small indicator - simpler cap - let topLed = drawFrostedGlassCap(topUV, topSize, topColor, isDataPresent, aa, select(0.0, 1.0, isDataPresent), topColor, select(0.0, 1.0, isDataPresent)); - finalColor = mix(finalColor, topLed.rgb, topLed.a); - - // Main display area with translucent glass and visible diode - let mainUV = btnUV - vec2(0.5, 0.5); - let mainSize = vec2(0.55, 0.45); - var noteColor = vec3(0.15); - var lightAmount = 0.0; - var noteGlow = 0.0; + // --- THREE-EMITTER SYSTEM --- + + // EMITTER 1 (TOP): Blue Note-On Indicator + // Lights up when note is triggered or playhead is on this step + let blueColor = vec3(0.15, 0.5, 1.0); + var topIntensity = 0.0; + if (!isMuted) { + if (ch.trigger > 0u) { + topIntensity = 1.0 + bloom; + } else if (playheadActivation > 0.5) { + topIntensity = playheadActivation * 0.6; + } + } + // EMITTER 2 (MIDDLE): Steady Note Color + // Shows pitch color whenever there's a note + var midColor = vec3(0.15); + var midIntensity = 0.12; // Base dim glow 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 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); - - // Beat-reactive: brighter pulse on kick - let beatBoost = 1.0 + kick * 0.5; - lightAmount = (activeVal * 0.9 + flash + strike + (linger * 2.5)) * clamp(ch.volume, 0.0, 1.2) * beatBoost; - if (isMuted) { lightAmount *= 0.2; } - noteGlow = lightAmount; - } + midColor = baseColor * instBright; - let displayColor = noteColor * max(lightAmount, 0.12) * (1.0 + bloom * 8.0); - let isLit = (lightAmount > 0.05); - // Use enhanced cap with visible diode - let mainPad = drawFrostedGlassCap(mainUV, mainSize, displayColor, isLit, aa, noteGlow, displayColor, noteGlow); - finalColor = mix(finalColor, mainPad.rgb, mainPad.a); - - // Brighter LED pulse when playhead is on this step - if (playheadActivation > 0.5 && hasNote) { - let pulseColor = mix(vec3(0.15, 0.5, 1.0), vec3(1.0, 0.55, 0.1), 0.5 + 0.5 * sin(beat * 6.2832)); - finalColor += pulseColor * playheadActivation * 0.15; + // Steady indication - doesn't blink, just shows note presence + midIntensity = 0.6 + bloom * 2.0; + if (isMuted) { midIntensity *= 0.3; } } - // Bottom effect indicator (Orange pill) - let botUV = btnUV - vec2(0.5, 0.85); - let botSize = vec2(0.25, 0.12); - var effColor = vec3(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(1.0, 0.55, 0.1); - if (!isMuted) { effColor *= 0.6; isEffOn = true; } + // EMITTER 3 (BOTTOM): Amber Control Message Indicator + // Lights up when there's an effect or volume command + let amberColor = vec3(1.0, 0.55, 0.1); + var botIntensity = 0.0; + if (!isMuted && hasExpression) { + botIntensity = 0.8 + bloom; } - let botLed = drawFrostedGlassCap(botUV, botSize, effColor, isEffOn, aa, select(0.0, 0.7, isEffOn), effColor, select(0.0, 0.7, isEffOn)); - finalColor = mix(finalColor, botLed.rgb, botLed.a); + // --- RENDER UNIFIED GLASS LENS --- + let lensUV = btnUV - vec2(0.5, 0.5); + let lensSize = vec2(0.55, 0.75); + + let lens = drawThreeEmitterLens( + lensUV, lensSize, + blueColor, topIntensity, + midColor, midIntensity, + amberColor, botIntensity, + aa + ); + + finalColor = mix(finalColor, lens.rgb, lens.a); + + // Add external glow when active + if (topIntensity > 0.0 || botIntensity > 0.0 || midIntensity > 0.5) { + let totalActivity = topIntensity + botIntensity + (midIntensity - 0.12); + let glowColor = mix(midColor, blueColor, topIntensity * 0.5); + let glowColor2 = mix(glowColor, amberColor, botIntensity * 0.5); + let externalGlow = glowColor2 * totalActivity * bloom * 2.0 * exp(-length(p) * 4.0); + finalColor += externalGlow; + } } // Kick reactive glow diff --git a/hooks/useWebGPURender.ts b/hooks/useWebGPURender.ts index b9cba4d..ee85e25 100644 --- a/hooks/useWebGPURender.ts +++ b/hooks/useWebGPURender.ts @@ -300,7 +300,8 @@ export function useWebGPURender( const p = renderParamsRef.current; const isHighPrec = shaderFile.includes('v0.36') || shaderFile.includes('v0.37') || shaderFile.includes('v0.38') || shaderFile.includes('v0.39') || shaderFile.includes('v0.40') || shaderFile.includes('v0.42') || shaderFile.includes('v0.43') || shaderFile.includes('v0.44') || shaderFile.includes('v0.45') || shaderFile.includes('v0.46') || shaderFile.includes('v0.47') || shaderFile.includes('v0.48') || shaderFile.includes('v0.49') || shaderFile.includes('v0.50'); const packFunc = isHighPrec ? packPatternMatrixHighPrecision : packPatternMatrix; - cellsBufferRef.current = createBufferWithData(device, packFunc(p.matrix, p.padTopChannel), GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST); + const { packedData } = packFunc(p.matrix, p.padTopChannel); + cellsBufferRef.current = createBufferWithData(device, packedData, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST); if (layoutType === 'extended') { const numRows = p.matrix?.numRows ?? DEFAULT_ROWS; @@ -370,12 +371,7 @@ export function useWebGPURender( if (cellsBufferRef.current) cellsBufferRef.current.destroy(); const isHighPrec = shaderFile.includes('v0.36') || shaderFile.includes('v0.37') || shaderFile.includes('v0.38') || shaderFile.includes('v0.39') || shaderFile.includes('v0.40') || shaderFile.includes('v0.42') || shaderFile.includes('v0.43') || shaderFile.includes('v0.44') || shaderFile.includes('v0.45') || shaderFile.includes('v0.46') || shaderFile.includes('v0.47') || shaderFile.includes('v0.48') || shaderFile.includes('v0.49') || shaderFile.includes('v0.50'); const packFunc = isHighPrec ? packPatternMatrixHighPrecision : packPatternMatrix; - const packedData = packFunc(p.matrix, p.padTopChannel); - let noteCount = 0; - for (let i = 0; i < packedData.length; i += 2) { - const note = ((packedData[i] ?? 0) >> 24) & 0xFF; - if (note > 0) noteCount++; - } + const { packedData, noteCount } = packFunc(p.matrix, p.padTopChannel); console.log(`[PatternDisplay] Packed data contains ${noteCount} notes in ${packedData.length / 2} cells`); cellsBufferRef.current = createBufferWithData(device, packedData, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST); if (layoutTypeRef.current === 'extended') { diff --git a/mod-player-shaders/hooks/useGPUBuffers.ts b/mod-player-shaders/hooks/useGPUBuffers.ts index 35cc69a..20e37d0 100644 --- a/mod-player-shaders/hooks/useGPUBuffers.ts +++ b/mod-player-shaders/hooks/useGPUBuffers.ts @@ -80,16 +80,22 @@ const parsePackedB = (text: string) => { ((effCode & 0xff) << 8) | (effParam & 0xff); }; -export const packPatternMatrix = (matrix: PatternMatrix | null, padTopChannel = false): Uint32Array => { +export interface PackedPatternData { + packedData: Uint32Array; + noteCount: number; +} + +export const packPatternMatrix = (matrix: PatternMatrix | null, padTopChannel = false): PackedPatternData => { const rawChannels = matrix?.numChannels ?? DEFAULT_CHANNELS; const numRows = matrix?.numRows ?? DEFAULT_ROWS; const numChannels = padTopChannel ? rawChannels + 1 : rawChannels; - const packed = new Uint32Array(numRows * numChannels * 2); + const packedData = new Uint32Array(numRows * numChannels * 2); - if (!matrix) return packed; + if (!matrix) return { packedData, noteCount: 0 }; const { rows } = matrix; const startCol = padTopChannel ? 1 : 0; + let noteCount = 0; for (let r = 0; r < numRows; r++) { const rowCells = rows[r] || []; @@ -127,23 +133,24 @@ export const packPatternMatrix = (matrix: PatternMatrix | null, padTopChannel = note = encodeNoteText(notePart); wordB = parsePackedB(text) >>> 0; } + if (note > 0) noteCount++; } // Always write packed data for this cell position - packed[offset] = ((note & 0xFF) << 24) | ((inst & 0xFF) << 16) | ((volCmd & 0xFF) << 8) | (volVal & 0xFF); - packed[offset + 1] = wordB; + packedData[offset] = ((note & 0xFF) << 24) | ((inst & 0xFF) << 16) | ((volCmd & 0xFF) << 8) | (volVal & 0xFF); + packedData[offset + 1] = wordB; } } - return packed; + return { packedData, noteCount }; }; -export const packPatternMatrixHighPrecision = (matrix: PatternMatrix | null, padTopChannel = false): Uint32Array => { +export const packPatternMatrixHighPrecision = (matrix: PatternMatrix | null, padTopChannel = false): PackedPatternData => { const rawChannels = matrix?.numChannels ?? DEFAULT_CHANNELS; const numRows = matrix?.numRows ?? DEFAULT_ROWS; const numChannels = padTopChannel ? rawChannels + 1 : rawChannels; - const packed = new Uint32Array(numRows * numChannels * 2); + const packedData = new Uint32Array(numRows * numChannels * 2); - if (!matrix) return packed; + if (!matrix) return { packedData, noteCount: 0 }; const { rows } = matrix; const startCol = padTopChannel ? 1 : 0; @@ -187,15 +194,15 @@ export const packPatternMatrixHighPrecision = (matrix: PatternMatrix | null, pad } // ALWAYS write packed data for this cell position - packed[offset] = ((note & 0xFF) << 24) | ((inst & 0xFF) << 16) | ((volCmd & 0xFF) << 8) | (volVal & 0xFF); - packed[offset + 1] = ((effCmd & 0xFF) << 8) | (effVal & 0xFF); + packedData[offset] = ((note & 0xFF) << 24) | ((inst & 0xFF) << 16) | ((volCmd & 0xFF) << 8) | (volVal & 0xFF); + packedData[offset + 1] = ((effCmd & 0xFF) << 8) | (effVal & 0xFF); } } // DEBUG: Log packing statistics console.log(`[packPatternMatrixHighPrecision] Packed ${notesPacked} notes into ${totalCells} cells (${numRows} rows x ${numChannels} channels)`); - return packed; + return { packedData, noteCount: notesPacked }; }; export const createBufferWithData = ( @@ -302,9 +309,10 @@ export function useGPUBuffers({ } const packFunc = isHighPrecision ? packPatternMatrixHighPrecision : packPatternMatrix; + const { packedData } = packFunc(matrix, padTopChannel); cellsBufferRef.current = createBufferWithData( device, - packFunc(matrix, padTopChannel), + packedData, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST ); diff --git a/utils/gpuPacking.ts b/utils/gpuPacking.ts index 525ae89..13bb517 100644 --- a/utils/gpuPacking.ts +++ b/utils/gpuPacking.ts @@ -174,16 +174,22 @@ export const encodeNoteText = (notePart: string): number => { return noteBase > 0 ? (octave + 1) * 12 + noteBase : 0; }; -export const packPatternMatrix = (matrix: PatternMatrix | null, padTopChannel = false): Uint32Array => { +export interface PackedPatternData { + packedData: Uint32Array; + noteCount: number; +} + +export const packPatternMatrix = (matrix: PatternMatrix | null, padTopChannel = false): PackedPatternData => { const rawChannels = matrix?.numChannels ?? DEFAULT_CHANNELS; const numRows = matrix?.numRows ?? DEFAULT_ROWS; const numChannels = padTopChannel ? rawChannels + 1 : rawChannels; - const packed = new Uint32Array(numRows * numChannels * 2); + const packedData = new Uint32Array(numRows * numChannels * 2); - if (!matrix) return packed; + if (!matrix) return { packedData, noteCount: 0 }; const { rows } = matrix; const startCol = padTopChannel ? 1 : 0; + let noteCount = 0; for (let r = 0; r < numRows; r++) { const rowCells = rows[r] || []; @@ -208,26 +214,27 @@ export const packPatternMatrix = (matrix: PatternMatrix | null, padTopChannel = const instMatch = text.match(/(\d{1,3})$/); inst = instMatch?.[1] ? Math.min(255, parseInt(instMatch[1], 10)) : 0; note = encodeNoteText(notePart); - packed[offset + 1] = parsePackedB(text) >>> 0; + packedData[offset + 1] = parsePackedB(text) >>> 0; } + if (note > 0) noteCount++; } - packed[offset] = ((note & 0xFF) << 24) | ((inst & 0xFF) << 16) | ((volCmd & 0xFF) << 8) | (volVal & 0xFF); + packedData[offset] = ((note & 0xFF) << 24) | ((inst & 0xFF) << 16) | ((volCmd & 0xFF) << 8) | (volVal & 0xFF); if (!cell?.text) { - packed[offset + 1] = ((effCmd & 0xFF) << 8) | (effVal & 0xFF); + packedData[offset + 1] = ((effCmd & 0xFF) << 8) | (effVal & 0xFF); } } } - return packed; + return { packedData, noteCount }; }; -export const packPatternMatrixHighPrecision = (matrix: PatternMatrix | null, padTopChannel = false): Uint32Array => { +export const packPatternMatrixHighPrecision = (matrix: PatternMatrix | null, padTopChannel = false): PackedPatternData => { const rawChannels = matrix?.numChannels ?? DEFAULT_CHANNELS; const numRows = matrix?.numRows ?? DEFAULT_ROWS; const numChannels = padTopChannel ? rawChannels + 1 : rawChannels; - const packed = new Uint32Array(numRows * numChannels * 2); + const packedData = new Uint32Array(numRows * numChannels * 2); - if (!matrix) return packed; + if (!matrix) return { packedData, noteCount: 0 }; const { rows } = matrix; const startCol = padTopChannel ? 1 : 0; @@ -261,14 +268,14 @@ export const packPatternMatrixHighPrecision = (matrix: PatternMatrix | null, pad if (note > 0) notesPacked++; } - packed[offset] = ((note & 0xFF) << 24) | ((inst & 0xFF) << 16) | ((volCmd & 0xFF) << 8) | (volVal & 0xFF); - packed[offset + 1] = ((effCmd & 0xFF) << 8) | (effVal & 0xFF); + packedData[offset] = ((note & 0xFF) << 24) | ((inst & 0xFF) << 16) | ((volCmd & 0xFF) << 8) | (volVal & 0xFF); + packedData[offset + 1] = ((effCmd & 0xFF) << 8) | (effVal & 0xFF); } } console.log(`[packPatternMatrixHighPrecision] Packed ${notesPacked} notes into ${totalCells} cells (${numRows} rows x ${numChannels} channels)`); - return packed; + return { packedData, noteCount: notesPacked }; }; export const createBufferWithData = (device: GPUDevice, data: ArrayBufferView | ArrayBuffer, usage: GPUBufferUsageFlags): GPUBuffer => {