Aesthetikk was meant to clone after Lightroom, VSCO. The goal is to provide what ever LUTs I've collected over the years ( 970 of em ) and a editing experience that is like native ( when you apply a filters or drag sliders , it just works instantly.
That responsiveness comes from a color engine, a chain of GPU stages that similar to how video games render frames.
This post walks through how that engine works. We start at the bottom (the shaders that touch pixels) and climb up to the React hooks that connect sliders to the GPU. By the end, you should be able to trace a single slider movement from the UI all the way to the canvas.
The original challenge
You cannot send every slider tweak to a backend. It would be slow, expensive, and could break anytime.
Instead, Aesthetikk treats photo editing like a video game rendering a frame: upload the image to the GPU once, run a series of image filters on the graphics card, show the result on a <canvas>.
Each stop on the line is an effect. If a slider is at zero, that stop is skipped. No work, no cost.
That skip-when-off rule matters. A photo with only a filter applied might run 1-2 GPU passes instead of all 11. In real world, this happens all the time since you're not gonna be a psycho and tweak every single sliders and apply all the filters. 🤔
$1. The pixel workers (GLSL shaders)
Everything visual starts with fragment shaders, small programs that run once per pixel.
All effects share one vertex shader (a full-screen quad). Only the fragment shader changes between effects.
The build setup loads .glsl files as plain strings:
// turbopack / webpack rule: treat .glsl files as importable strings
const glslRule = {
'*.glsl': { loaders: ['raw-loader'], as: '*.js' },
};Each effect module imports its shader directly:
// At build time this becomes a string, not a runtime fetch
import bloomExtractShader from '../shaders/bloom-extract.glsl';
// ShaderManager compiles once at init:
// compileProgram({ vertex: sharedVertex, fragment: bloomExtractShader })When you move a slider, the shader code does not recompile. Only the uniforms change: the numbers (brightness, blur strength, and so on) sent into the shader before each draw call. A shader manager compiles each program once and caches it by key.
That is the lowest layer: strings of GLSL, compiled once, driven by numbers that change every frame.
Part 2: One effect, one processor
Each effect wraps its shader inside a processor class.
Every processor extends a shared base class. It owns:
- Which GLSL shaders to use
- How to bind input/output textures
- Any extra internal passes (bloom does several; more on that later)
Simple effects like grain or vignette need one draw call. Complex ones like bloom need multiple passes with their own off-screen buffers.
Processors doesnt know about React. Doesnt know about sliders either. They only know input texture -> output buffer, here are the parameters. Run the shader.
Part 3: The eleven stages, in order
Processors do not run in random order. A fixed registry defines the chain:
| Order | Stage | What it does |
|---|---|---|
| 1 | LUT | Apply a color preset (lookup table) |
| 2 | Adjustments | Brightness, contrast, saturation, exposure |
| 3 | Grain | Film grain noise |
| 4 | Bloom | Glow on bright areas |
| 5 | Diffusion | Soft highlight glow |
| 6 | Chromatic | RGB channel separation |
| 7 | Tone curves | Interactive luminance curve |
| 8 | Spectrum | Split-toning by shadows/mids/highlights |
| 9 | Vignette | Darkened edges |
| 10 | Film fade | Lifted blacks / faded look |
| 11 | Clarity | Local contrast (unsharp mask) |
Each stage is registered with three hooks:
type PipelineStage = {
id: string;
order: number;
createProcessor: () => BaseProcessor;
isEnabled: (ctx: PipelineContext) => boolean;
configResolver?: (ctx: PipelineContext) => StageConfig;
};
const lutStage: PipelineStage = {
id: 'lut',
order: 1,
createProcessor: () => new LutProcessor(context),
isEnabled: isLutEnabled, // skip if no preset selected
configResolver: resolveLutConfig, // build params for this pass
};isEnabled is the optimization gate. Before any GPU work runs, the pipeline asks: does this effect actually need to run?
For basic adjustments, that check looks like this:
const isAdjustmentsEnabled = (context: PipelineContext): boolean => {
const { brightness, contrast, saturation, exposure } = context.effects.adjustments;
// Any knob off zero? Run the pass. All at default? Skip it.
return (
Math.abs(brightness) > 1e-4 ||
Math.abs(contrast) > 1e-4 ||
Math.abs(saturation) > 1e-4 ||
Math.abs(exposure) > 1e-4
);
};All sliders at default? The adjustments shader never fires.
Part 4: The pipeline that chains textures together
The effects pipeline is the conductor. It walks the eleven stages in order, skips disabled ones, and passes each output to the next input.
Think of it as a relay race. Each stage renders into an off-screen buffer (a framebuffer, or FBO), then hands its output texture to the next runner.
The main loop:
let currentTexture = params.inputTexture;
for (const stage of this.stages) {
const enabled = stage.isEnabled({ effects: effectParams, options: params });
if (!enabled) continue; // disabled stage costs nothing
const config = stage.configResolver?.({ effects: effectParams, options: params });
const processor = stage.createProcessor();
const framebufferResource = this.getFramebufferResource(
stage.id,
params.width,
params.height
);
const resultTexture = processor.render(
{
inputTexture: currentTexture,
width: params.width,
height: params.height,
outputFramebuffer: framebufferResource.framebuffer,
outputTexture: framebufferResource.texture,
},
config
);
// Hand this stage's output to the next stage
currentTexture = resultTexture ?? framebufferResource.texture;
}
return { outputTexture: currentTexture };The final output texture is still off-screen. Something else has to draw it onto the visible canvas. That is the job of the color engine's public API.
Part 5: The color engine (public front door)
The color engine is what the rest of the app talks to. It hides the WebGL context, shader manager, framebuffer manager, and pipeline behind a small API:
initialize()attaches to a canvas and spins up WebGL2applyEffects()runs the full pipeline on an input texturerenderTextureToCanvas()blits the final texture to the screen
When the editor mounts, initialization sets up the GPU context with options tuned for live editing:
initialize(options: ColorEngineInitOptions) {
const { canvas, lutCache } = options;
const gl = this.contextManager.initialize(canvas, {
alpha: true,
desynchronized: true, // lower compositor latency during drags
preserveDrawingBuffer: false, // we redraw every frame; no need to keep pixels
premultipliedAlpha: true,
antialias: true,
});
this.lutCache = lutCache ?? null;
this.pipeline = new EffectsPipeline({
gl,
shaderManager: this.shaderManager,
framebufferManager: this.framebufferManager,
});
return gl;
}| Option | Why it matters |
|---|---|
desynchronized: true | Tells the browser not to wait for the compositor, for smoother slider response |
preserveDrawingBuffer: false | Saves memory; we redraw every frame anyway |
lutCache | Keeps hot preset textures on the GPU (covered in Caching & Memory Management) |
Before any shader runs, the engine also sanitizes slider values. The UI store holds raw numbers; the engine clamps them so bad input cannot produce black frames or NaN artifacts:
private sanitizeEffects(effects: EffectParams): EffectParams {
return {
adjustments: this.clampAdjustments(effects.adjustments),
grain: this.clampGrain(effects.grain),
bloom: this.clampBloom(effects.bloom),
diffusion: this.clampDiffusion(effects.diffusion),
chromatic: this.clampChromatic(effects.chromatic),
toneCurves: this.clampToneCurves(effects.toneCurves),
spectrum: this.clampSpectrum(effects.spectrum),
vignette: this.clampVignette(effects.vignette),
filmFade: this.clampFilmFade(effects.filmFade),
clarity: this.clampClarity(effects.clarity),
};
}Two layers of defense: the UI for user input, the engine for GPU safety.
Part 6: One frame, start to finish
Here is what happens when effects need to re-apply.
1. Build the image texture. A hook converts the loaded image into GPU memory:
const imageData = createImageData(img);
const imageTexture = colorEngine.createTextureFromImageData(imageData, {
flipY: true, // WebGL origin differs from HTML image origin
});2. Load the preset texture (if one is selected). Presets come from cache: IndexedDB first, then uploaded to VRAM:
let lutTexture: WebGLTexture | null = null;
if (selectedLutId) {
lutTexture = await colorEngine.loadCachedLutTexture(selectedLutId);
}3. Run the pipeline.
const result = colorEngine.applyEffects({
width: canvasRef.current.width,
height: canvasRef.current.height,
inputTexture: imageTexture,
effects, // all slider values bundled
lutTexture: lutTexture ?? null,
lutIntensity: 1.0,
});
if (result.outputTexture) {
colorEngine.renderTextureToCanvas(result.outputTexture);
}
colorEngine.deleteTexture(imageTexture); // temp texture, gone after this frame4. Show it. The engine draws the off-screen result onto the <canvas> the user sees.
The temp image texture is deleted every frame. Preset textures stay cached. That balance keeps memory under control (more in Caching & Memory Management).
Part 7: When one stage is really five (bloom)
Most effects are one shader draw. Bloom is not.
Bloom extracts bright pixels, blurs them with a Kawase blur (small repeated passes instead of one huge Gaussian kernel, much faster on mobile GPUs), then composites the glow back onto the original:
Inside the bloom processor:
const KAWASE_ITERATIONS = 5;
let currentTexture = extractResource.texture;
let pingPong = 0;
for (let i = 0; i < KAWASE_ITERATIONS; i++) {
const blurKey = `bloom_kawase_${pingPong}`;
const blurResource = this.bindFramebufferResource(blurKey, width, height);
this.runKawasePass({
inputTexture: currentTexture,
outputFramebuffer: blurResource.framebuffer,
iteration: i,
offset: (i + 1) * 1.5,
});
currentTexture = blurResource.texture;
pingPong = 1 - pingPong; // swap between two blur buffers
}
// Final composite pass blends blurred glow back onto the original image
this.runCompositePass({
originalTexture: inputTexture,
bloomTexture: currentTexture,
outputFramebuffer: outputFramebuffer,
});From the pipeline's point of view, bloom is still one stage. Inside that stage, the processor manages its own mini-pipeline. That pattern keeps the main loop simple while still allowing heavy effects.
Part 8: Climbing back up to React
The engine does not know sliders exist. React hooks bridge that gap.
Layer 1: Canvas and engine setup
A canvas hook runs when the editor loads:
- Creates a color engine instance
- Loads the user's image onto the canvas
- Tracks canvas size and WebGL readiness
- Tears everything down on unmount
This hook owns lifecycle. It answers: is the GPU ready, and do we have an image to work with?
Layer 2: Applying effects
A second hook owns what to render:
- Reads slider values from global state stores
- Bundles them into a single
effectsobject - Calls
applyEffects()when anything changes
const adjustments = useColorEngineEffectsStore((state) => state.adjustments);
const bloom = useColorEngineEffectsStore((state) => state.bloom);
const selectedLutId = useLutStore((state) => state.selectedLutId);
const toneCurves = useColorEngineEffectsStore((state) => state.toneCurves);
const clarity = useColorEngineEffectsStore((state) => state.clarity);
// ...spectrum, vignette, film fade, etc.When any of these change, one effect schedules a GPU update, but not on every micro-change (see below).
Layer 3: The UI components
Sliders and preset pickers are dumb in the best way. They write to global state. They do not call WebGL directly.
That separation means you can add a new slider component without touching the engine, as long as the store shape matches what the pipeline expects.
The full stack:
Three responsibilities, three layers:
| Layer | Role | Job |
|---|---|---|
| Public API | Color engine | Sanitize inputs, create textures, blit to canvas |
| Orchestrator | Effects pipeline | Walk stages, skip disabled ones, chain textures |
| Workers | Processors + shaders | Run the actual pixel math |
Part 9: Making sliders feel instant
Dragging a slider fires dozens of store updates per second. Running the full eleven-stage pipeline on every single update would choke the GPU.
RAF throttling (one update per frame)
Instead of debouncing (wait until the user stops dragging) or firing on every render, the hook coalesces updates to one GPU apply per animation frame:
const scheduleApplyEffects = useCallback(() => {
pendingApplyRef.current = true;
if (rafIdRef.current !== null) {
return; // a frame is already scheduled
}
rafIdRef.current = requestAnimationFrame(() => {
rafIdRef.current = null;
if (!pendingApplyRef.current) {
return;
}
pendingApplyRef.current = false;
applyCurrentEffectsRef.current(); // one full pipeline run for this frame
});
}, []);Many slider events in one frame become one pipeline run. Smooth at 60fps. (See Smooth Sliders: RAF and React Architecture for the full breakdown.)
AbortController for stale preset loads
If a user clicks preset A then quickly clicks preset B, the load for A is cancelled:
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
const { signal } = abortControllerRef.current;
const lutTexture = await colorEngine.loadCachedLutTexture(selectedLutId, {
signal,
});
if (signal.aborted) {
return; // user already picked a different preset
}No race where an old preset overwrites a new selection.
Shader program cache
Already mentioned in Part 1, worth repeating at this level: slider movement updates uniforms, not shader source. Recompilation only happens when the app loads or an effect is added, never mid-drag.
Adding a new effect
The pipeline design means new effects plug in without rewriting the engine:
- Write a GLSL fragment shader
- Create a processor class that extends the shared base
- Register a stage with
isEnabledandconfigResolver - Add slider state to the global store
No changes to the color engine's public API required.
Why WebGL, not Canvas 2D or CSS filters?
Canvas 2D cannot do per-pixel LUT lookup, multi-pass bloom, or interactive tone curves at full resolution in real time. CSS filters are limited and cannot be chained with fine control.
WebGL gives full control over every pixel, every pass. That is the tradeoff: more setup, total freedom.
Closing thought
The engine runs at the pixel level. The hooks translate interaction ( events ) into GPU commands. The UI stays unaware of shaders, and that separation is what makes the whole thing maintainable.
If you're building something similar, start with one effect and one shader. Get a texture in, get a texture out, apply to canvas. Then register a second stage. The assembly line grows from there. Thank you for your time !