fix: preserve dark artwork mood in ambient background

Two issues caused visible dark rectangles on simple/dark artwork:

1. Texture normalization always mapped to [30-240] regardless of source
   brightness. For dark artwork (Metallica), this amplified noise 5x and
   turned a dark album into a light gray background. Now uses
   brightness-aware blending: dark artwork gets gentle lift (preserves
   mood), colorful artwork gets full normalization.

2. Shader layer offsets were too large (up to 20% of texture), causing
   visible layer boundaries where each sampling layer showed different
   parts of the amplified texture. Reduced offsets by ~60% and tightened
   scale ranges for smoother parallax.
This commit is contained in:
vicrodh committed 2026-03-01 13:22:05 -06:00
1 parent 23aefcda80
commit 10d395a89e
2 files changed
+48 -26

No files matched your search

+14 -14
View File
@@ -66,32 +66,32 @@ void main() {
// All parameters are decorrelated for organic feel
// FAR LAYER: Background, slowest movement
float farScaleBase = 0.7;
float farScaleBreath = farScaleBase + sin(time * 0.1) * 0.05 * intensity;
float farRotation = sin(time * 0.05) * 0.03 * intensity; // ~1.7° max
float farScaleBase = 0.8;
float farScaleBreath = farScaleBase + sin(time * 0.1) * 0.03 * intensity;
float farRotation = sin(time * 0.05) * 0.02 * intensity;
vec2 farOffset = vec2(
sin(time * 0.08) * 0.20,
cos(time * 0.06) * 0.15
sin(time * 0.08) * 0.08,
cos(time * 0.06) * 0.06
) * intensity;
vec3 farLayer = sampleLayer(uv, farOffset, farScaleBreath, farRotation);
// MID LAYER: Middle ground, medium movement
float midScaleBase = 0.9;
float midScaleBreath = midScaleBase + sin(time * 0.15 + 1.0) * 0.04 * intensity;
float midRotation = sin(time * 0.08 + 2.0) * 0.025 * intensity; // ~1.4° max
float midScaleBreath = midScaleBase + sin(time * 0.15 + 1.0) * 0.03 * intensity;
float midRotation = sin(time * 0.08 + 2.0) * 0.015 * intensity;
vec2 midOffset = vec2(
cos(time * 0.12 + 2.0) * 0.14,
sin(time * 0.10 + 1.0) * 0.12
cos(time * 0.12 + 2.0) * 0.06,
sin(time * 0.10 + 1.0) * 0.05
) * intensity;
vec3 midLayer = sampleLayer(uv, midOffset, midScaleBreath, midRotation);
// NEAR LAYER: Foreground, fastest movement
float nearScaleBase = 1.3;
float nearScaleBreath = nearScaleBase + sin(time * 0.2 + 2.5) * 0.06 * intensity;
float nearRotation = sin(time * 0.12 + 4.0) * 0.02 * intensity; // ~1.1° max
float nearScaleBase = 1.15;
float nearScaleBreath = nearScaleBase + sin(time * 0.2 + 2.5) * 0.04 * intensity;
float nearRotation = sin(time * 0.12 + 4.0) * 0.015 * intensity;
vec2 nearOffset = vec2(
sin(time * 0.18 + 3.5) * 0.08,
cos(time * 0.15 + 2.5) * 0.07
sin(time * 0.18 + 3.5) * 0.04,
cos(time * 0.15 + 2.5) * 0.03
) * intensity;
vec3 nearLayer = sampleLayer(uv, nearOffset, nearScaleBreath, nearRotation);
+34 -12
View File
@@ -162,18 +162,20 @@ function applyColorAdjustments(
const ctx = canvas.getContext('2d');
if (!ctx) return sourceCanvas;
// First pass: strong saturation boost, moderate brightness
ctx.filter = 'saturate(2.5) brightness(0.85) contrast(1.1)';
// First pass: saturation boost, moderate brightness
ctx.filter = 'saturate(2.0) brightness(0.85) contrast(1.1)';
ctx.drawImage(sourceCanvas, 0, 0);
// Second pass: normalize color range and lift shadows
// Second pass: brightness-aware normalization
// Dark artwork stays dark (preserves mood), colorful artwork gets full expansion
const imageData = ctx.getImageData(0, 0, size, size);
const data = imageData.data;
// Find actual min/max for each channel to normalize
// Measure source: per-channel range + average brightness
let minR = 255, maxR = 0;
let minG = 255, maxG = 0;
let minB = 255, maxB = 0;
let totalBrightness = 0;
for (let i = 0; i < data.length; i += 4) {
minR = Math.min(minR, data[i]);
@@ -182,26 +184,46 @@ function applyColorAdjustments(
maxG = Math.max(maxG, data[i + 1]);
minB = Math.min(minB, data[i + 2]);
maxB = Math.max(maxB, data[i + 2]);
totalBrightness += (data[i] + data[i + 1] + data[i + 2]) / 3;
}
// Target range: 30-240 (preserve some headroom, lift shadows)
const pixelCount = size * size;
const avgBrightness = totalBrightness / pixelCount;
// Normalization strength scales with source brightness:
// Very dark (avg < 20): ~0.15 strength (mostly gentle lift, preserve darkness)
// Medium (avg ~80): ~1.0 strength (full normalization)
// Already bright: 1.0 (full normalization)
const normStrength = Math.min(1.0, avgBrightness / 80);
const targetMin = 30;
const targetMax = 240;
const targetRange = targetMax - targetMin;
// Calculate normalization factors per channel
const rangeR = maxR - minR || 1;
const rangeG = maxG - minG || 1;
const rangeB = maxB - minB || 1;
// Gentle lift factor for dark artwork (1.5x brightness)
const liftFactor = 1.5;
for (let i = 0; i < data.length; i += 4) {
// Normalize each channel to target range
data[i] = targetMin + ((data[i] - minR) / rangeR) * targetRange;
data[i + 1] = targetMin + ((data[i + 1] - minG) / rangeG) * targetRange;
data[i + 2] = targetMin + ((data[i + 2] - minB) / rangeB) * targetRange;
// Full normalization target
const normR = targetMin + ((data[i] - minR) / rangeR) * targetRange;
const normG = targetMin + ((data[i + 1] - minG) / rangeG) * targetRange;
const normB = targetMin + ((data[i + 2] - minB) / rangeB) * targetRange;
// Gentle lift target (preserves relative color, just brighter)
const liftR = Math.min(255, data[i] * liftFactor);
const liftG = Math.min(255, data[i + 1] * liftFactor);
const liftB = Math.min(255, data[i + 2] * liftFactor);
// Blend: dark artwork → mostly lift, bright artwork → full normalization
data[i] = liftR + (normR - liftR) * normStrength;
data[i + 1] = liftG + (normG - liftG) * normStrength;
data[i + 2] = liftB + (normB - liftB) * normStrength;
// Boost reds slightly for warmth
data[i] = Math.min(255, data[i] * 1.12);
data[i] = Math.min(255, data[i] * 1.08);
}
ctx.putImageData(imageData, 0, 0);