feat: add non-destructive adjustment layers
This commit is contained in:
111
renderer/adjustment-pass.ts
Normal file
111
renderer/adjustment-pass.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user