sandbox/studio: realistic brushed-aluminium knob (multi-layer lighting)

Previous version was too flat. Real metal needs concentric brushing
(not radial), a strong horizontal specular band, multiple light
sources and a defined bevel.

Disc rendering now layers:
- Radial base gradient (matte foundation, slightly dark at edge)
- Concentric brushing rings (lathe-turned aluminium, alternating
  bright/dark with deterministic pseudo-random alpha)
- Vertical lighting overlay (light-from-above gradient)
- Horizontal specular band on the upper portion (light source reflection)
- Subtle rim light from below (typical of softbox-lit studio gear)
- Bright bevel crescent on the upper edge + dark crescent on the lower
- Inset shadow line where disc meets the dark rim

Indicator notch refined to read as engraved (dark groove + thin
bright lower lip catching light on the inner edge).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jose Luis Montañes
2026-05-01 18:08:23 +02:00
parent 249e2a2b96
commit 499b4f9e70

View File

@@ -491,69 +491,117 @@ function drawKnob(ctx, size, norm) {
ctx.arc(cx, cy, totalR - rimW / 2, 0, Math.PI * 2); ctx.arc(cx, cy, totalR - rimW / 2, 0, Math.PI * 2);
ctx.stroke(); ctx.stroke();
// (4) BIG METALLIC DISC — fills most of the inside (the "circulito metalico // (4a) METAL — base radial gradient (matte foundation, slightly dark at edge)
// que ocupa casi todo"). Radial gradient, brushed-metal feel. const baseGrad = ctx.createRadialGradient(cx, cy, 0, cx, cy, discR);
const disc = ctx.createRadialGradient( baseGrad.addColorStop(0, '#787268');
cx, cy - discR * 0.25, 0, baseGrad.addColorStop(0.7, '#5a544c');
cx, cy + discR * 0.4, discR * 1.4 baseGrad.addColorStop(1, '#36322d');
); ctx.fillStyle = baseGrad;
disc.addColorStop(0, '#b6b0a6'); // lit upper-center
disc.addColorStop(0.45, '#8a847a');
disc.addColorStop(0.85, '#46423d');
disc.addColorStop(1, '#2a2722'); // bottom dark
ctx.fillStyle = disc;
ctx.beginPath(); ctx.beginPath();
ctx.arc(cx, cy, discR, 0, Math.PI * 2); ctx.arc(cx, cy, discR, 0, Math.PI * 2);
ctx.fill(); ctx.fill();
// (5) brushed-metal radial striations (very subtle, deterministic) // (4b) CONCENTRIC BRUSHING — lathe-turned aluminium look (signature on
// Moog/Roland synths). Pseudo-random alpha per ring, alternating
// bright/dark bands, deterministic so the rings don't shimmer.
ctx.save(); ctx.save();
ctx.beginPath(); ctx.beginPath();
ctx.arc(cx, cy, discR - 0.5, 0, Math.PI * 2); ctx.arc(cx, cy, discR - 0.3, 0, Math.PI * 2);
ctx.clip(); ctx.clip();
for (let i = 0; i < 96; i++) { for (let r = discR; r > 0.5; r -= 0.55) {
const a = (i / 96) * Math.PI * 2; const seed = Math.floor(r * 17.31);
const alpha = 0.025 + ((i * 9301 + 49297) % 233) / 233 * 0.04; const n = ((seed * 9301 + 49297) % 233) / 233;
ctx.strokeStyle = `rgba(255, 240, 220, ${alpha.toFixed(3)})`; const a = 0.04 + n * 0.10;
ctx.strokeStyle = n > 0.55
? `rgba(255, 245, 225, ${a.toFixed(3)})`
: `rgba(10, 8, 6, ${(a * 0.85).toFixed(3)})`;
ctx.lineWidth = 0.5; ctx.lineWidth = 0.5;
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(cx, cy); ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.lineTo(cx + Math.cos(a) * discR, cy + Math.sin(a) * discR);
ctx.stroke(); ctx.stroke();
} }
ctx.restore(); ctx.restore();
// (6) thin shadow line where disc meets rim (depth) // (4c) VERTICAL LIGHTING — light from above. Strong on top, dark below.
ctx.strokeStyle = 'rgba(0,0,0,0.55)'; const vert = ctx.createLinearGradient(0, cy - discR, 0, cy + discR);
vert.addColorStop(0, 'rgba(255, 248, 230, 0.32)');
vert.addColorStop(0.40, 'rgba(255, 248, 230, 0.04)');
vert.addColorStop(0.55, 'rgba(0, 0, 0, 0.05)');
vert.addColorStop(1, 'rgba(0, 0, 0, 0.45)');
ctx.fillStyle = vert;
ctx.beginPath();
ctx.arc(cx, cy, discR, 0, Math.PI * 2);
ctx.fill();
// (4d) HORIZONTAL SPECULAR BAND — the bright reflection of the light
// source running across the upper part of the disc.
const band = ctx.createLinearGradient(0, cy - discR * 0.65, 0, cy - discR * 0.05);
band.addColorStop(0, 'rgba(255, 252, 240, 0)');
band.addColorStop(0.5, 'rgba(255, 252, 240, 0.42)');
band.addColorStop(1, 'rgba(255, 252, 240, 0)');
ctx.fillStyle = band;
ctx.beginPath();
ctx.arc(cx, cy, discR, 0, Math.PI * 2);
ctx.fill();
// (4e) RIM LIGHT FROM BELOW — subtle glow on the bottom edge (typical of
// studio gear photographed under softboxes).
const rimLight = ctx.createLinearGradient(0, cy + discR * 0.55, 0, cy + discR);
rimLight.addColorStop(0, 'rgba(255, 230, 200, 0)');
rimLight.addColorStop(1, 'rgba(255, 230, 200, 0.14)');
ctx.fillStyle = rimLight;
ctx.beginPath();
ctx.arc(cx, cy, discR, 0, Math.PI * 2);
ctx.fill();
// (5) BEVEL — bright crescent on the upper edge
ctx.strokeStyle = 'rgba(255, 252, 240, 0.55)';
ctx.lineWidth = 0.9;
ctx.beginPath();
ctx.arc(cx, cy, discR - 0.5, Math.PI * 1.10, Math.PI * 1.90);
ctx.stroke();
// (5b) BEVEL — dark crescent on the lower edge (shadow side of the bevel)
ctx.strokeStyle = 'rgba(0, 0, 0, 0.50)';
ctx.lineWidth = 0.9;
ctx.beginPath();
ctx.arc(cx, cy, discR - 0.5, Math.PI * 0.10, Math.PI * 0.90);
ctx.stroke();
// (6) inset shadow line where disc meets rim
ctx.strokeStyle = 'rgba(0, 0, 0, 0.65)';
ctx.lineWidth = 0.7; ctx.lineWidth = 0.7;
ctx.beginPath(); ctx.beginPath();
ctx.arc(cx, cy, discR + 0.4, 0, Math.PI * 2); ctx.arc(cx, cy, discR + 0.3, 0, Math.PI * 2);
ctx.stroke(); ctx.stroke();
// (7) bright highlight crescent on the top edge of the disc // (7) INDICATOR — small engraved dark notch on the disc, rotates with value.
ctx.strokeStyle = 'rgba(255,255,255,0.30)'; // Drawn as a tapered triangle with a subtle bottom highlight to look
ctx.lineWidth = 0.8; // like it's incised into the metal rather than printed on top.
ctx.beginPath();
ctx.arc(cx, cy, discR - 0.5, Math.PI * 1.18, Math.PI * 1.82);
ctx.stroke();
// (8) small DARK NOTCH indicator on the disc, rotates with value
const ang = startA + norm * (endA - startA); const ang = startA + norm * (endA - startA);
const tipR = discR * 0.86; const tipR = discR * 0.86;
const baseR = discR * 0.55; const baseR = discR * 0.50;
const perp = ang + Math.PI / 2; const perp = ang + Math.PI / 2;
const half = size * 0.028; const half = size * 0.026;
const tipX = cx + Math.cos(ang) * tipR; const tipX = cx + Math.cos(ang) * tipR;
const tipY = cy + Math.sin(ang) * tipR; const tipY = cy + Math.sin(ang) * tipR;
const bX = cx + Math.cos(ang) * baseR; const bX = cx + Math.cos(ang) * baseR;
const bY = cy + Math.sin(ang) * baseR; const bY = cy + Math.sin(ang) * baseR;
ctx.fillStyle = 'rgba(8,7,6,0.95)'; // dark fill (the engraved groove)
ctx.fillStyle = 'rgba(6, 5, 4, 0.92)';
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(tipX, tipY); ctx.moveTo(tipX, tipY);
ctx.lineTo(bX + Math.cos(perp) * half, bY + Math.sin(perp) * half); ctx.lineTo(bX + Math.cos(perp) * half, bY + Math.sin(perp) * half);
ctx.lineTo(bX - Math.cos(perp) * half, bY - Math.sin(perp) * half); ctx.lineTo(bX - Math.cos(perp) * half, bY - Math.sin(perp) * half);
ctx.closePath(); ctx.closePath();
ctx.fill(); ctx.fill();
// tiny bright lower lip (light catches the inner edge of the groove)
ctx.strokeStyle = 'rgba(255, 250, 230, 0.25)';
ctx.lineWidth = 0.4;
ctx.beginPath();
ctx.moveTo(bX + Math.cos(perp) * half, bY + Math.sin(perp) * half);
ctx.lineTo(tipX, tipY);
ctx.stroke();
} }
document.querySelectorAll('canvas.hw-knob').forEach((canvas) => { document.querySelectorAll('canvas.hw-knob').forEach((canvas) => {