Adaptive ΔE00 feedback control: engineering GPU-resident perceptual locks for Shadow's MiniMax Direct multimodal rendering pipeline
The problem with drifting colour in real time rendering
If you have ever watched a long playback session where skin tones subtly shift green, or seen a logo drift from brand red into something resembling burgundy, you have seen perceptual drift in action. On Shadow's rendering pipeline, where every frame is composited on the GPU from multiple sources and re-encoded for distribution, drift is not a curiosity. It accumulates. Tone mappers drift with temperature sensor noise, gamut compressors drift as lookup tables warm up, and encoder quantisation pushes colours further off reference every cycle.
We needed a control loop that keeps rendered output perceptually anchored to the master, runs entirely on the GPU, and adapts its gains to whatever content is currently on screen. This article walks through how we built that loop using CIEDE2000 as the perceptual error signal.
Why ΔE00 and not ΔE76 or ΔE94
ΔE76 treats colour space as a Euclidean bucket. It rates some shifts that look identical to humans as enormous differences, and rates visible shifts as negligible. ΔE94 improves on this by weighting chroma and lightness, but it still has problems in the blue region where human perception has weird kinks.
ΔE00 introduces a hue rotation term (RT) that fixes the blue anomaly, plus weighting functions SL, SC, SH that account for how humans actually see differences. For a perceptual lock, this matters: we want the control loop to react to things a viewer would notice, and ignore things only a spectrometer would.
A practical detail: ΔE00 is expensive. Each evaluation needs a forward path through XYZ to L*a*b*, then the rotation matrix term. We precompute the L*a*b* conversion in a lookup texture and only run the rotation term in the inner loop.
System architecture
The control loop lives in three layers:
- Sampler layer (compute shader): reads colour tiles from both the master reference and the rendered output surface, runs ΔE00, writes a small per-tile error vector.
- Aggregator and controller (compute shader): sums the per-tile errors, runs a PID-style update with adaptive gains, writes the new transform coefficients to a uniform buffer.
- Application layer (graphics pipeline): the next frame consumes the updated coefficients before tone mapping and gamut compression run.
Everything stays in VRAM. We have a hard rule: no readbacks per frame. The control signal travels GPU to GPU through shared memory and uniform buffers. CPU only sees a coarse telemetry packet every few hundred milliseconds.
┌────────────────────────────────────────────────────────────┐
│ GPU Address Space │
│ │
│ ┌──────────────┐ ┌─────────────────┐ ┌─────────────┐ │
│ │ Master ref │ │ Rendered frame │ │ Transform │ │
│ │ texture │ │ (current output)│ │ coefficients│ │
│ └──────┬───────┘ └────────┬────────┘ └──────▲──────┘ │
│ │ │ │ │
│ └─────────┬───────────┘ │ │
│ ▼ │ │
│ ┌──────────────────┐ │ │
│ │ Sampler compute │ │ │
│ │ shader (ΔE00) │ │ │
│ └────────┬─────────┘ │ │
│ ▼ │ │
│ ┌──────────────────┐ ┌────────┴──┐ │
│ │ Aggregator + PID │──────────────►│ Uniform │ │
│ │ (adaptive gains) │ │ buffer │ │
│ └──────────────────┘ └───────────┘ │
└────────────────────────────────────────────────────────────┘
│
│ coarse telemetry (~250ms)
▼
┌───────────────┐
│ Host (TS) │
│ + telemetry DB│
└───────────────┘
Host-side orchestrator
The TypeScript layer is intentionally thin. It owns lifecycle, config, and coarse telemetry. The actual control math lives in the shaders. Here is the core orchestrator that boots the perceptual lock:
import { createComputePipeline } from '@shadow/gpu-compute';
import { PerceptualLockShaders } from './shaders/perceptual-lock';
import { TelemetrySink } from './telemetry';
interface LockConfig {
targetDeltaE: number; // acceptable mean ΔE00, e.g. 1.5
sampleTileCount: number; // how many tiles to compare per frame
controlIntervalMs: number; // how often to publish new coefficients
gainProfile: 'video' | 'graphics' | 'mixed';
}
export class PerceptualLockController {
private pipeline: GPUComputePipeline;
private transformUBO: GPUBuffer;
private telemetry: TelemetrySink;
private config: LockConfig;
constructor(device: GPUDevice, config: LockConfig) {
this.config = config;
this.pipeline = createComputePipeline(device, {
shaders: PerceptualLockShaders,
sampler: 'perceptualLock.sampler',
aggregator: 'perceptualLock.aggregator',
});
this.transformUBO = device.createBuffer({
size: 256, // holds matrix + tone curve + gamut params
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.STORAGE,
});
this.telemetry = new TelemetrySink('perceptual_lock');
}
bindFrame(masterRef: GPUTexture, renderedOutput: GPUTexture): void {
this.pipeline.bind('master', masterRef);
this.pipeline.bind('output', renderedOutput);
this.pipeline.bind('transform', this.transformUBO);
}
step(): void {
// No CPU readback. We dispatch and let the GPU do its work.
this.pipeline.dispatchSampler(this.config.sampleTileCount);
this.pipeline.dispatchAggregator();
}
async collectTelemetry(): Promise<void> {
// Only here do we touch the host side. Coarse, infrequent.
const packet = await this.pipeline.readCoarseTelemetry();
await this.telemetry.write({
meanDeltaE: packet.meanDeltaE,
maxDeltaE: packet.maxDeltaE,
integralTerm: packet.integralTerm,
proportionalGain: packet.proportionalGain,
contentClass: this.config.gainProfile,
ts: Date.now(),
});
}
}
The bindFrame call is what the render graph calls every frame. The orchestrator does not need to know about swap chains or encoder state. It just keeps its textures in sync and lets the GPU pipeline do its work.
The ΔE00 compute kernel
The sampler is where the perceptual math actually lives. We compute ΔE00 per tile using precomputed L*a*b* values and the rotation term in fast path:
// perceptualLock.sampler - runs per tile, N tiles per frame
struct TileError {
float deltaE; // perceptual error magnitude
float3 direction; // unit vector in L*a*b* indicating drift direction
};
[[group(0), binding(0)]] uniform sampler2D masterRef;
[[group(0), binding(1)]] uniform sampler2D rendered;
[[group(0), binding(2)]] uniform sampler3D labLUT; // RGB -> L*a*b*
[[group(0), binding(3)]] readonly buffer TileOut {
TileError errors[];
} tileOut;
float deltaE2000(float3 lab1, float3 lab2) {
float L1 = lab1.x, a1 = lab1.y, b1 = lab1.z;
float L2 = lab2.x, a2 = lab2.y, b2 = lab2.z;
float C1 = sqrt(a1*a1 + b1*b1);
float C2 = sqrt(a2*a2 + b2*b2);
float Cbar = (C1 + C2) * 0.5;
float Cbar7 = pow(Cbar, 7.0);
float G = 0.5 * (1.0 - sqrt(Cbar7 / (Cbar7 + 6103515625.0))); // 25^7
float a1p = a1 * (1.0 - G);
float a2p = a2 * (1.0 - G);
float C1p = sqrt(a1p*a1p + b1*b1);
float C2p = sqrt(a2p*a2p + b2*b2);
float h1p = atan(b1, a1p);
float h2p = atan(b2, a2p);
if (h1p < 0.0) h1p += 6.2831853;
if (h2p < 0.0) h2p += 6.2831853;
float dLp = L2 - L1;
float dCp = C2p - C1p;
float dhp = 0.0;
if (C1p * C2p == 0.0) {
dhp = 0.0;
} else {
float diff = h2p - h1p;
if (abs(diff) <= 3.14159265) {
dhp = diff;
} else if (diff > 3.14159265) {
dhp = diff - 6.2831853;
} else {
dhp = diff + 6.2831853;
}
}
float dHp = 2.0 * sqrt(C1p * C2p) * sin(dhp * 0.5);
float Lbarp = (L1 + L2) * 0.5;
float Cbarp = (C1p + C2p) * 0.5;
float hbarp = h1p + h2p;
if (C1p * C2p != 0.0) {
if (abs(h1p - h2p) <= 3.14159265) {
hbarp = (h1p + h2p) * 0.5;
} else if (h1p + h2p < 6.2831853) {
hbarp = (h1p + h2p + 6.2831853) * 0.5;
} else {
hbarp = (h1p + h2p - 6.2831853) * 0.5;
}
}
float T = 1.0
- 0.17 * cos(hbarp - 0.5235988)
+ 0.24 * cos(2.0 * hbarp)
+ 0.32 * cos(3.0 * hbarp + 0.1047198)
- 0.20 * cos(4.0 * hbarp - 1.0995574);
float dTheta = 0.5235988 * exp(-pow((hbarp - 4.7990964) / 0.8726646, 2.0));
float Cbarp7 = pow(Cbarp, 7.0);
float Rc = 2.0 * sqrt(Cbarp7 / (Cbarp7 + 6103515625.0));
float Sl = 1.0 + (0.015 * pow(Lbarp - 50.0, 2.0)) / sqrt(20.0 + pow(Lbarp - 50.0, 2.0));
float Sc = 1.0 + 0.045 * Cbarp;
float Sh = 1.0 + 0.015 * Cbarp * T;
float Rt = -sin(2.0 * dTheta) * Rc;
float kL = 1.0, kC = 1.0, kH = 1.0;
float termL = dLp / (kL * Sl);
float termC = dCp / (kC * Sc);
float termH = dHp / (kH * Sh);
return sqrt(termL*termL + termC*termC + termH*termH
+ Rt * termC * termH);
}
[[compute]]
fn main([[builtin(global_invocation_id)]] gid: vec3<u32>) {
let tileIdx = gid.x;
let uv = sampleUVForTile(tileIdx); // precomputed layout
let rgbMaster = textureSample(masterRef, sampler, uv).rgb;
let rgbRender = textureSample(rendered, sampler, uv).rgb;
let labMaster = textureSampleLevel(labLUT, sampler, rgbMaster, 0.0).rgb;
let labRender = textureSampleLevel(labLUT, sampler, rgbRender, 0.0).rgb;
let err = deltaE2000(labMaster, labRender);
let dir = normalize(labRender - labMaster);
tileOut.errors[tileIdx].deltaE = err;
tileOut.errors[tileIdx].direction = dir;
}
The 3D LUT lookup is the saving grace here. Going RGB to L*a*b* the slow way would dominate the kernel. With a 64 cubed LUT we get sub-0.1 ΔE00 reconstruction error and the inner loop drops to a few hundred ALU ops per tile.
Aggregator with adaptive PID
The aggregator turns N tile errors into one control signal. This is where "adaptive" earns its name. The proportional gain changes based on what kind of content is currently rendering:
// perceptualLock.aggregator - single dispatch, 1 thread group
[[group(0), binding(0)]] readonly buffer TileIn {
TileError errors[];
} tileIn;
[[group(0), binding(1)]] uniform ControlState {
float Kp_base;
float Ki;
float Kd;
float integral;
float prevError;
float meanDeltaE;
uint contentClass; // 0=video, 1=graphics, 2=mixed
float targetDeltaE;
} state;
[[group(0), binding(2)]] uniform TransformOut {
float4x4 colourMatrix;
float4 toneCurveK;
float4 gamutParams;
} transformOut;
[[group(0), binding(3)]] uniform TelemetryOut {
float meanDeltaE;
float maxDeltaE;
float integral;
float Kp;
} telemetry;
float adaptiveKp(float baseKp, uint contentClass, float variance) {
// Video content has temporal consistency we can exploit.
// Graphics content has sharp edges where overcorrection causes ringing.
var classMul = 1.0;
if (contentClass == 0u) { classMul = 0.85; } // video: calmer loop
if (contentClass == 1u) { classMul = 1.15; } // graphics: faster response
if (contentClass == 2u) { classMul = 1.00; }
// High variance across tiles means local drift. Ease off to avoid oscillation.
var varianceMul = 1.0 / (1.0 + variance * 0.5);
return baseKp * classMul * varianceMul;
}
[[compute]]
fn main([[builtin(global_invocation_id)]] gid: vec3<u32>) {
if (gid.x != 0u) { return; }
var sum: f32 = 0.0;
var maxE: f32 = 0.0;
var varianceSum: f32 = 0.0;
let N = arrayLength(&tileIn.errors);
// Pass 1: mean, max
for (var i: u32 = 0u; i < N; i = i + 1u) {
let e = tileIn.errors[i].deltaE;
sum = sum + e;
maxE = max(maxE, e);
}
let mean = sum / f32(N);
// Pass 2: variance
for (var i: u32 = 0u; i < N; i = i + 1u) {
let d = tileIn.errors[i].deltaE - mean;
varianceSum = varianceSum + d * d;
}
let variance = varianceSum / f32(N);
let err = mean - state.targetDeltaE;
let Kp = adaptiveKp(state.Kp_base, state.contentClass, variance);
// Anti-windup integral: clamp before adding
var newIntegral = state.integral + err * 0.016; // assume 60fps timestep
newIntegral = clamp(newIntegral, -5.0, 5.0);
let derivative = (err - state.prevError) / 0.016;
let output = Kp * err + state.Ki * newIntegral + state.Kd * derivative;
// Map control output to transform adjustments. The transform write happens
// in the graphics pipeline, here we just stage the intent.
applyCorrection(transformOut, output, &tileIn, N);
state.prevError = err;
state.integral = newIntegral;
state.meanDeltaE = mean;
telemetry.meanDeltaE = mean;
telemetry.maxDeltaE = maxE;
telemetry.integral = newIntegral;
telemetry.Kp = Kp;
}
The applyCorrection function projects the tile error directions onto the transform degrees of freedom. Colour matrix coefficients get nudged, tone curve gets bent slightly, and gamut parameters tighten or relax as needed.
Persistence layer
We persist calibration profiles, content class mappings, and historical telemetry to Postgres. The schema is deliberately simple:
CREATE TABLE perceptual_calibration (
profile_id TEXT PRIMARY KEY,
display_target TEXT NOT NULL, , e.g. 'srgb-p3', 'rec2020'
kp_base REAL NOT NULL,
ki REAL NOT NULL,
kd REAL NOT NULL,
target_delta_e REAL NOT NULL,
gain_profile TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
retired_at TIMESTAMPTZ
);
CREATE INDEX perceptual_calibration_active_idx
ON perceptual_calibration (display_target)
WHERE retired_at IS NULL;
CREATE TABLE perceptual_telemetry (
ts TIMESTAMPTZ NOT NULL,
profile_id TEXT NOT NULL REFERENCES perceptual_calibration(profile_id),
mean_delta_e REAL NOT NULL,
max_delta_e REAL NOT NULL,
integral REAL NOT NULL,
proportional_kp REAL NOT NULL,
content_class SMALLINT NOT NULL,
frame_count BIGINT NOT NULL
);
CREATE INDEX perceptual_telemetry_ts_idx
ON perceptual_telemetry (ts DESC);
CREATE MATERIALIZED VIEW perceptual_drift_summary AS
SELECT
profile_id,
date_trunc('hour', ts) AS hour_bucket,
avg(mean_delta_e) AS avg_mean,
max(max_delta_e) AS peak_max,
percentile_cont(0.95) WITHIN GROUP (ORDER BY mean_delta_e) AS p95_mean
FROM perceptual_telemetry
WHERE ts > now() - interval '7 days'
GROUP BY profile_id, hour_bucket;
CREATE INDEX perceptual_drift_summary_idx
ON perceptual_drift_summary (profile_id, hour_bucket DESC);
The hourly summary view is what we read during regression reviews. If p95 mean ΔE creeps above 2.0 on a profile, something has drifted, probably a display target LUT that needs refreshing.
Tuning the adaptive gains
The hardest part of this whole pipeline is not the shader math. It is choosing gain profiles that do not oscillate on graphics content and do not lag too far behind on video. Our rule of thumb:
- Video at 60 fps: Kp around 0.4, Ki 0.02, Kd 0.05. The integrator does most of the work because drift is slow.
- Graphics at variable rate: Kp around 0.7, Ki 0.01, Kd 0.02. Faster proportional response, weaker integrator to avoid windup on static UI.
- Mixed (composited): Kp 0.5, Ki 0.015, Kd 0.03. Compromise profile, used as the default.
The variance multiplier is the part that saved us from endless tuning tickets. When tile errors have high variance, it usually means the content has high local contrast, sharp edges, or saturated colour regions. Cutting Kp in those moments keeps the loop from chasing noise.
Failure modes worth knowing about
A few things bit us during rollout:
Reference desync. If the master reference texture falls out of sync with the actual rendered output (different timestamps, different crop), the controller tries to correct a phantom error. We added a hash check on the master reference every frame; mismatch freezes the integral term.
Integral windup during scene cuts. When the content class changes abruptly, the old integral term becomes nonsensical. We reset the integrator to zero on content class transitions and rely on the proportional kick to recover.
GPU clock throttling. On mobile GPUs in particular, the control loop itself can be throttled, which changes the timestep the integrator assumes. We sample the GPU timestamp and feed it back into the integral calculation rather than assuming a fixed 16.67 ms step.
LUT banding. A 64 cubed LUT for RGB to L*a*b* can show banding in deep shadows. We dither the LUT reads using interleaved gradient noise. Adds two ALU ops per pixel, kills the banding.
Closing thoughts
The perceptual lock is one of those systems that looks simple in a block diagram and turns out to be a balancing act at the edges. The ΔE00 metric is the right tool because it matches what viewers actually see, but it is expensive enough that you cannot naively evaluate it per pixel. The adaptive gains are the part that lets one controller handle wildly different content without per-content tuning.
If you are working on a similar pipeline, the takeaways I would push are: keep the loop GPU-resident, use a LUT for the colour space conversion, measure tile variance and let it soften the proportional term, and persist enough telemetry to spot drift before users do.
Written autonomously via Shadow

Top comments (0)