feat: add non-destructive adjustment layers

This commit is contained in:
syntaxbullet
2026-07-11 12:31:06 +02:00
parent b928fb599b
commit 606426c885
30 changed files with 427 additions and 49 deletions

111
renderer/adjustment-pass.ts Normal file
View File

@@ -0,0 +1,111 @@
import type { ColorAdjustment } from "@core/adjustment-layer";
import type { ScreenRect, WebGlRendererContext } from "./types";
export type AdjustmentPass = {
apply(adjustment: ColorAdjustment, opacity: number, clip: ScreenRect): void;
dispose(): void;
};
const vertexSource = `#version 300 es
in vec2 a_position;
void main(){ gl_Position=vec4(a_position,0.,1.); }`;
const fragmentSource = `#version 300 es
precision highp float;
uniform sampler2D u_pixels; uniform vec2 u_size; uniform float u_brightness,u_contrast,u_saturation,u_opacity; uniform vec3 u_balance;
out vec4 outColor;
void main(){ vec2 uv=gl_FragCoord.xy/u_size; vec4 original=texture(u_pixels,uv); vec3 c=original.rgb+vec3(u_brightness)+u_balance; c=(c-0.5)*(1.0+u_contrast)+0.5; float l=dot(c,vec3(.2126,.7152,.0722)); c=clamp(vec3(l)+(c-vec3(l))*(1.0+u_saturation),0.0,1.0); outColor=vec4(mix(original.rgb,c,u_opacity),original.a); }`;
/** Renderer-owned framebuffer adjustment resources, reused across layers/frames. */
export function createAdjustmentPass(context: WebGlRendererContext): AdjustmentPass {
const { gl, canvas } = context;
const program = createProgram(gl, vertexSource, fragmentSource);
const texture = gl.createTexture();
const buffer = gl.createBuffer();
if (!program || !texture || !buffer) throw new Error("Failed to create adjustment pass");
const position = gl.getAttribLocation(program, "a_position");
const size = requiredUniform(gl, program, "u_size");
const brightness = requiredUniform(gl, program, "u_brightness");
const contrast = requiredUniform(gl, program, "u_contrast");
const saturation = requiredUniform(gl, program, "u_saturation");
const opacityLocation = requiredUniform(gl, program, "u_opacity");
const balance = requiredUniform(gl, program, "u_balance");
const pixels = requiredUniform(gl, program, "u_pixels");
let allocatedWidth = 0;
let allocatedHeight = 0;
let disposed = false;
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]), gl.STATIC_DRAW);
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
return {
apply(adjustment, opacity, clip) {
if (disposed || canvas.width < 1 || canvas.height < 1) return;
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, texture);
if (allocatedWidth !== canvas.width || allocatedHeight !== canvas.height) {
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, canvas.width, canvas.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
allocatedWidth = canvas.width;
allocatedHeight = canvas.height;
}
gl.copyTexSubImage2D(gl.TEXTURE_2D, 0, 0, 0, 0, 0, canvas.width, canvas.height);
gl.useProgram(program);
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.enableVertexAttribArray(position);
gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);
gl.uniform1i(pixels, 0);
gl.uniform2f(size, canvas.width, canvas.height);
gl.uniform1f(brightness, adjustment.brightness);
gl.uniform1f(contrast, adjustment.contrast);
gl.uniform1f(saturation, adjustment.saturation);
gl.uniform1f(opacityLocation, Math.max(0, Math.min(1, opacity)));
gl.uniform3f(balance, adjustment.colorBalance.red, adjustment.colorBalance.green, adjustment.colorBalance.blue);
gl.scissor(clip.x, canvas.height - clip.y - clip.h, clip.w, clip.h);
gl.disable(gl.BLEND);
gl.drawArrays(gl.TRIANGLES, 0, 6);
gl.enable(gl.BLEND);
},
dispose() {
if (disposed) return;
disposed = true;
gl.deleteTexture(texture);
gl.deleteBuffer(buffer);
gl.deleteProgram(program);
},
};
}
function requiredUniform(gl: WebGL2RenderingContext, program: WebGLProgram, name: string) {
const location = gl.getUniformLocation(program, name);
if (!location) throw new Error(`Missing adjustment uniform: ${name}`);
return location;
}
function createProgram(gl: WebGL2RenderingContext, vertex: string, fragment: string) {
const vertexShader = compile(gl, gl.VERTEX_SHADER, vertex);
const fragmentShader = compile(gl, gl.FRAGMENT_SHADER, fragment);
if (!vertexShader || !fragmentShader) return undefined;
const program = gl.createProgram();
if (!program) return undefined;
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
gl.deleteShader(vertexShader);
gl.deleteShader(fragmentShader);
if (gl.getProgramParameter(program, gl.LINK_STATUS)) return program;
gl.deleteProgram(program);
return undefined;
}
function compile(gl: WebGL2RenderingContext, type: number, source: string) {
const shader = gl.createShader(type);
if (!shader) return undefined;
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (gl.getShaderParameter(shader, gl.COMPILE_STATUS)) return shader;
gl.deleteShader(shader);
return undefined;
}

View File

@@ -9,6 +9,7 @@ import { clearScreenRect } from "./clear-rect";
import type { ImageTextureRenderer } from "./image-textures";
import { documentRectToScreenRect } from "./screen-rect";
import type { RgbaColor, ScreenRect, WebGlRendererContext } from "./types";
import type { AdjustmentPass } from "./adjustment-pass";
const imageLayerColor: RgbaColor = [0.38, 0.42, 0.5, 1];
const imageLayerInsetColor: RgbaColor = [0.48, 0.54, 0.64, 1];
@@ -16,7 +17,7 @@ const hiddenMaskOverlayColor: RgbaColor = [1, 0.08, 0.08, 0.45];
const comparisonDividerColor: RgbaColor = [1, 1, 1, 0.9];
const maskRevealPreviewOpacity = 0.28;
export function renderLayers(context: WebGlRendererContext, document: ImageDocument, editor: EditorState, imageTextureRenderer: ImageTextureRenderer) {
export function renderLayers(context: WebGlRendererContext, document: ImageDocument, editor: EditorState, imageTextureRenderer: ImageTextureRenderer, adjustmentPass: AdjustmentPass) {
const documentIndex = createDocumentReadIndex(document);
const generationCandidate = selectedGenerationCandidate(editor);
@@ -24,7 +25,7 @@ export function renderLayers(context: WebGlRendererContext, document: ImageDocum
if (!artboard.visible) continue;
const clipRect = documentRectToScreenRect(context.canvas, artboard.bounds, editor.viewport);
const maskLayerIds = documentIndex.maskLayerIdsByArtboardId.get(artboard.id) ?? emptyLayerIds;
renderLayerTree(context, documentIndex, editor, artboard.layers, imageTextureRenderer, clipRect, maskLayerIds);
renderLayerTree(context, documentIndex, editor, artboard.layers, imageTextureRenderer, adjustmentPass, clipRect, maskLayerIds);
if (generationCandidate?.placement.artboardId === artboard.id) renderGenerationCandidatePreview(context, editor, generationCandidate, imageTextureRenderer, clipRect);
}
}
@@ -39,6 +40,7 @@ function renderLayerTree(
editor: EditorState,
layers: readonly Layer[],
imageTextureRenderer: ImageTextureRenderer,
adjustmentPass: AdjustmentPass,
clipRect: ScreenRect,
maskLayerIds: ReadonlySet<string>,
) {
@@ -57,6 +59,10 @@ function renderLayerTree(
for (const child of layer.children) stack.push({ layer: child, clipRect: effectiveClipRect, inheritedOpacity: effectiveOpacity });
continue;
}
if (layer.type === "adjustment") {
adjustmentPass.apply(layer.adjustment, effectiveOpacity, effectiveClipRect);
continue;
}
renderLeafLayer(context, documentIndex, editor, layer, imageTextureRenderer, effectiveClipRect, effectiveOpacity);
}
}
@@ -65,7 +71,7 @@ function renderLeafLayer(
context: WebGlRendererContext,
documentIndex: DocumentReadIndex,
editor: EditorState,
layer: Exclude<Layer, { type: "group" }>,
layer: Extract<Layer, { type: "image" | "raster" }>,
imageTextureRenderer: ImageTextureRenderer,
effectiveClipRect: ScreenRect,
effectiveOpacity: number,
@@ -82,7 +88,7 @@ function renderLeafLayer(
const asset = assetWithBrushStrokePreview(documentIndex.assetById.get(layer.assetId), editor);
const layerMask = getLayerMask(layer);
const maskLayer = !editingMaskLayer && layerMask?.enabled ? documentIndex.layerById.get(layerMask.maskLayerId) : undefined;
const maskAsset = assetWithBrushStrokePreview(maskLayer && maskLayer.type !== "group" ? documentIndex.assetById.get(maskLayer.assetId) : undefined, editor);
const maskAsset = assetWithBrushStrokePreview(maskLayer && maskLayer.type !== "group" && maskLayer.type !== "adjustment" ? documentIndex.assetById.get(maskLayer.assetId) : undefined, editor);
const maskBounds = maskLayer ? resolveIndexedLayerBounds(documentIndex, maskLayer) : undefined;
const maskRect = maskBounds ? documentRectToScreenRect(context.canvas, maskBounds, editor.viewport) : undefined;
const activeMaskTarget = Boolean(editor.maskEdit?.targetLayerId === layer.id && editor.maskEdit.maskLayerId === layerMask?.maskLayerId);

View File

@@ -8,6 +8,7 @@ import { generationCandidatePreviewAssets, renderLayers } from "./layers";
import { renderSelectionOverlay } from "./selection";
import { renderTransformControls } from "./transform-controls";
import type { WebGlRendererContext } from "./types";
import { createAdjustmentPass } from "./adjustment-pass";
export type RenderFrame = {
document: ImageDocument;
@@ -34,6 +35,7 @@ export function createRenderer(canvas: HTMLCanvasElement, backend: RendererBacke
const rendererContext: WebGlRendererContext = { gl: context, canvas };
const checkerboardRenderer = createCheckerboardRenderer(rendererContext);
const brushPreviewRenderer = createOptionalBrushPreviewRenderer(rendererContext);
const adjustmentPass = createAdjustmentPass(rendererContext);
let lastFrame: RenderFrame | undefined;
let rerenderQueued = false;
const imageTextureRenderer = createImageTextureRenderer(rendererContext, () => {
@@ -63,7 +65,7 @@ export function createRenderer(canvas: HTMLCanvasElement, backend: RendererBacke
if (artboard.visible) renderArtboard(rendererContext, artboard, frame.editor.viewport, checkerboardRenderer);
}
imageTextureRenderer.syncAssets([...frame.document.assets, ...generationCandidatePreviewAssets(frame.editor)]);
renderLayers(rendererContext, frame.document, frame.editor, imageTextureRenderer);
renderLayers(rendererContext, frame.document, frame.editor, imageTextureRenderer, adjustmentPass);
if (!frame.editor.maskEdit) {
renderSelectionOverlay(rendererContext, frame.document, frame.editor);
@@ -76,6 +78,7 @@ export function createRenderer(canvas: HTMLCanvasElement, backend: RendererBacke
dispose() {
checkerboardRenderer.dispose();
imageTextureRenderer.dispose();
adjustmentPass.dispose();
brushPreviewRenderer?.dispose();
},
};