mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
feat: migrate GPU renderer from WebGL to wgpu/WASM
This commit is contained in:
@@ -2,18 +2,14 @@ import { createOffscreenCanvas } from "./canvas-utils";
|
||||
import { effectsRegistry, resolveEffectPasses } from "@/lib/effects";
|
||||
import { buildDefaultParamValues } from "@/lib/registry";
|
||||
import type { ParamValues } from "@/lib/params";
|
||||
import { applyMultiPassEffect } from "./webgl/webgl-utils";
|
||||
import type { EffectPassData } from "./webgl/webgl-utils";
|
||||
import { gpuRenderer } from "./gpu-renderer";
|
||||
|
||||
const PREVIEW_SIZE = 160;
|
||||
const PREVIEW_IMAGE_PATH = "/effects/preview.jpg";
|
||||
|
||||
class EffectPreviewService {
|
||||
private previewGl: WebGLRenderingContext | null = null;
|
||||
private previewCanvas: OffscreenCanvas | HTMLCanvasElement | null = null;
|
||||
private testSourceCanvas: OffscreenCanvas | HTMLCanvasElement | null = null;
|
||||
private previewImageElement: HTMLImageElement | null = null;
|
||||
private programCache = new Map<string, WebGLProgram>();
|
||||
private onReadyCallbacks = new Set<() => void>();
|
||||
|
||||
readonly PREVIEW_SIZE = PREVIEW_SIZE;
|
||||
@@ -58,7 +54,7 @@ class EffectPreviewService {
|
||||
width: uniformDimensions?.width ?? size,
|
||||
height: uniformDimensions?.height ?? size,
|
||||
});
|
||||
const result = this.applyWebGlEffect({
|
||||
const result = this.applyGpuEffect({
|
||||
source,
|
||||
width: size,
|
||||
height: size,
|
||||
@@ -114,29 +110,6 @@ class EffectPreviewService {
|
||||
return canvas;
|
||||
}
|
||||
|
||||
private getOrCreatePreviewContext({
|
||||
width,
|
||||
height,
|
||||
}: {
|
||||
width: number;
|
||||
height: number;
|
||||
}): { canvas: OffscreenCanvas | HTMLCanvasElement; gl: WebGLRenderingContext } {
|
||||
if (!this.previewCanvas || !this.previewGl) {
|
||||
this.previewCanvas = createOffscreenCanvas({ width, height });
|
||||
this.previewGl = this.previewCanvas.getContext("webgl", {
|
||||
premultipliedAlpha: false,
|
||||
}) as WebGLRenderingContext | null;
|
||||
if (!this.previewGl) {
|
||||
throw new Error("WebGL not supported");
|
||||
}
|
||||
}
|
||||
if (this.previewCanvas.width !== width || this.previewCanvas.height !== height) {
|
||||
this.previewCanvas.width = width;
|
||||
this.previewCanvas.height = height;
|
||||
}
|
||||
return { canvas: this.previewCanvas, gl: this.previewGl };
|
||||
}
|
||||
|
||||
private getTestSource({
|
||||
width,
|
||||
height,
|
||||
@@ -154,7 +127,7 @@ class EffectPreviewService {
|
||||
return this.testSourceCanvas;
|
||||
}
|
||||
|
||||
private applyWebGlEffect({
|
||||
private applyGpuEffect({
|
||||
source,
|
||||
width,
|
||||
height,
|
||||
@@ -163,28 +136,14 @@ class EffectPreviewService {
|
||||
source: CanvasImageSource;
|
||||
width: number;
|
||||
height: number;
|
||||
passes: EffectPassData[];
|
||||
passes: ReturnType<typeof resolveEffectPasses>;
|
||||
}): OffscreenCanvas | HTMLCanvasElement {
|
||||
const { canvas: glCanvas, gl } = this.getOrCreatePreviewContext({ width, height });
|
||||
|
||||
applyMultiPassEffect({
|
||||
context: gl,
|
||||
return gpuRenderer.applyEffect({
|
||||
source,
|
||||
width,
|
||||
height,
|
||||
passes,
|
||||
programCache: this.programCache,
|
||||
});
|
||||
|
||||
const outputCanvas = createOffscreenCanvas({ width, height });
|
||||
const outputCtx = outputCanvas.getContext("2d") as
|
||||
| CanvasRenderingContext2D
|
||||
| OffscreenCanvasRenderingContext2D
|
||||
| null;
|
||||
if (outputCtx) {
|
||||
outputCtx.drawImage(glCanvas, 0, 0, width, height);
|
||||
}
|
||||
return outputCanvas;
|
||||
}) as OffscreenCanvas | HTMLCanvasElement;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import {
|
||||
applyEffectPasses,
|
||||
applyMaskFeather as applyMaskFeatherWasm,
|
||||
initializeGpu,
|
||||
} from "opencut-wasm";
|
||||
import type { EffectPass, EffectUniformValue } from "@/lib/effects/types";
|
||||
|
||||
let initializeGpuRendererPromise: Promise<void> | null = null;
|
||||
|
||||
export function initializeGpuRenderer(): Promise<void> {
|
||||
if (!initializeGpuRendererPromise) {
|
||||
initializeGpuRendererPromise = initializeGpu();
|
||||
}
|
||||
const promise = initializeGpuRendererPromise;
|
||||
if (!promise) {
|
||||
throw new Error("GPU renderer initialization promise was not created");
|
||||
}
|
||||
return promise;
|
||||
}
|
||||
|
||||
export const gpuRenderer = {
|
||||
applyEffect({
|
||||
source,
|
||||
width,
|
||||
height,
|
||||
passes,
|
||||
}: {
|
||||
source: CanvasImageSource;
|
||||
width: number;
|
||||
height: number;
|
||||
passes: EffectPass[];
|
||||
}): CanvasImageSource {
|
||||
if (passes.length === 0) {
|
||||
return source;
|
||||
}
|
||||
|
||||
const sourceCanvas = ensureOffscreenCanvas({
|
||||
source,
|
||||
width,
|
||||
height,
|
||||
label: "effect source",
|
||||
});
|
||||
return applyEffectPasses({
|
||||
source: sourceCanvas,
|
||||
width,
|
||||
height,
|
||||
passes: serializeEffectPasses(passes),
|
||||
});
|
||||
},
|
||||
|
||||
applyMaskFeather({
|
||||
maskCanvas,
|
||||
width,
|
||||
height,
|
||||
feather,
|
||||
}: {
|
||||
maskCanvas: CanvasImageSource;
|
||||
width: number;
|
||||
height: number;
|
||||
feather: number;
|
||||
}): CanvasImageSource {
|
||||
const sourceCanvas = ensureOffscreenCanvas({
|
||||
source: maskCanvas,
|
||||
width,
|
||||
height,
|
||||
label: "mask source",
|
||||
});
|
||||
return applyMaskFeatherWasm({
|
||||
mask: sourceCanvas,
|
||||
width,
|
||||
height,
|
||||
feather,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
function ensureOffscreenCanvas({
|
||||
source,
|
||||
width,
|
||||
height,
|
||||
label,
|
||||
}: {
|
||||
source: CanvasImageSource;
|
||||
width: number;
|
||||
height: number;
|
||||
label: string;
|
||||
}): OffscreenCanvas {
|
||||
if (source instanceof OffscreenCanvas) {
|
||||
return source;
|
||||
}
|
||||
|
||||
if (typeof OffscreenCanvas === "undefined") {
|
||||
throw new Error(`OffscreenCanvas is required for the GPU ${label}`);
|
||||
}
|
||||
|
||||
const canvas = new OffscreenCanvas(width, height);
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) {
|
||||
throw new Error(`Failed to get 2d context for the GPU ${label}`);
|
||||
}
|
||||
context.clearRect(0, 0, width, height);
|
||||
context.drawImage(source, 0, 0, width, height);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function serializeEffectPasses(passes: EffectPass[]) {
|
||||
return passes.map((pass) => ({
|
||||
shader: pass.shader,
|
||||
uniforms: Object.entries(pass.uniforms).map(([name, value]) => ({
|
||||
name,
|
||||
value: normalizeUniformValue(value),
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeUniformValue(value: EffectUniformValue): number[] {
|
||||
return typeof value === "number" ? [value] : value;
|
||||
}
|
||||
@@ -1,54 +1,20 @@
|
||||
import jfaDistanceShader from "@/lib/masks/shaders/jfa-distance.frag.glsl";
|
||||
import { getWebGLContext, readResult } from "./webgl/webgl-context";
|
||||
import { computeSignedDistanceField, runPass } from "./webgl/jfa";
|
||||
import { compileProgram, createTexture } from "./webgl/webgl-utils";
|
||||
|
||||
export function applyMaskFeather({
|
||||
maskCanvas,
|
||||
width,
|
||||
height,
|
||||
feather,
|
||||
}: {
|
||||
maskCanvas: CanvasImageSource;
|
||||
width: number;
|
||||
height: number;
|
||||
feather: number;
|
||||
}): OffscreenCanvas | HTMLCanvasElement {
|
||||
const { context, programCache } = getWebGLContext({ width, height });
|
||||
const sourceTexture = createTexture({ context, source: maskCanvas });
|
||||
|
||||
const sdf = computeSignedDistanceField({
|
||||
context,
|
||||
programCache,
|
||||
sourceTexture,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
|
||||
const distanceProgram = compileProgram({
|
||||
context,
|
||||
fragmentShaderSource: jfaDistanceShader,
|
||||
programCache,
|
||||
});
|
||||
|
||||
runPass({
|
||||
context,
|
||||
program: distanceProgram,
|
||||
inputTexture: sdf.insideTexture,
|
||||
target: null,
|
||||
width,
|
||||
height,
|
||||
uniforms: { u_feather_half: feather / 2.0 },
|
||||
extraBindings: [
|
||||
{ unit: 1, texture: sdf.outsideTexture, name: "u_jfa_outside" },
|
||||
],
|
||||
});
|
||||
|
||||
context.deleteTexture(sourceTexture);
|
||||
sdf.cleanup();
|
||||
|
||||
context.bindTexture(context.TEXTURE_2D, null);
|
||||
context.bindFramebuffer(context.FRAMEBUFFER, null);
|
||||
|
||||
return readResult({ width, height });
|
||||
}
|
||||
import { gpuRenderer } from "./gpu-renderer";
|
||||
|
||||
export function applyMaskFeather({
|
||||
maskCanvas,
|
||||
width,
|
||||
height,
|
||||
feather,
|
||||
}: {
|
||||
maskCanvas: CanvasImageSource;
|
||||
width: number;
|
||||
height: number;
|
||||
feather: number;
|
||||
}): OffscreenCanvas | HTMLCanvasElement {
|
||||
return gpuRenderer.applyMaskFeather({
|
||||
maskCanvas,
|
||||
width,
|
||||
height,
|
||||
feather,
|
||||
}) as OffscreenCanvas | HTMLCanvasElement;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { videoCache } from "@/services/video-cache/service";
|
||||
import type { RetimeConfig } from "@/lib/timeline";
|
||||
import type { CanvasRenderer } from "../canvas-renderer";
|
||||
import { createOffscreenCanvas } from "../canvas-utils";
|
||||
import { webglEffectRenderer } from "../webgl/webgl-effect-renderer";
|
||||
import { gpuRenderer } from "../gpu-renderer";
|
||||
import { BaseNode } from "./base-node";
|
||||
import { loadImageSource, type CachedImageSource } from "./image-node";
|
||||
|
||||
@@ -131,7 +131,7 @@ export class BlurBackgroundNode extends BaseNode<BlurBackgroundNodeParams> {
|
||||
sigmaX: this.params.blurIntensity * (renderer.width / 1920),
|
||||
sigmaY: this.params.blurIntensity * (renderer.height / 1080),
|
||||
});
|
||||
const effectResult = webglEffectRenderer.applyEffect({
|
||||
const effectResult = gpuRenderer.applyEffect({
|
||||
source: offscreen as CanvasImageSource,
|
||||
width: renderer.width,
|
||||
height: renderer.height,
|
||||
|
||||
@@ -1,81 +1,81 @@
|
||||
import type { CanvasRenderer } from "../canvas-renderer";
|
||||
import { effectsRegistry, resolveEffectPasses } from "@/lib/effects";
|
||||
import type { ParamValues } from "@/lib/params";
|
||||
import { BaseNode } from "./base-node";
|
||||
import { webglEffectRenderer } from "../webgl/webgl-effect-renderer";
|
||||
|
||||
const TIME_EPSILON = 1e-6;
|
||||
|
||||
export type EffectLayerNodeParams = {
|
||||
effectType: string;
|
||||
effectParams: ParamValues;
|
||||
timeOffset: number;
|
||||
duration: number;
|
||||
};
|
||||
|
||||
function isInRange({
|
||||
time,
|
||||
timeOffset,
|
||||
duration,
|
||||
}: {
|
||||
time: number;
|
||||
timeOffset: number;
|
||||
duration: number;
|
||||
}): boolean {
|
||||
return (
|
||||
time >= timeOffset - TIME_EPSILON &&
|
||||
time < timeOffset + duration + TIME_EPSILON
|
||||
);
|
||||
}
|
||||
|
||||
// snapshots whatever is currently on the canvas, applies the effect, draws it back
|
||||
export class EffectLayerNode extends BaseNode<EffectLayerNodeParams> {
|
||||
async render({
|
||||
renderer,
|
||||
time,
|
||||
}: {
|
||||
renderer: CanvasRenderer;
|
||||
time: number;
|
||||
}): Promise<void> {
|
||||
if (
|
||||
!isInRange({
|
||||
time,
|
||||
timeOffset: this.params.timeOffset,
|
||||
duration: this.params.duration,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const source = renderer.context.canvas as CanvasImageSource;
|
||||
|
||||
const effectDefinition = effectsRegistry.get(this.params.effectType);
|
||||
|
||||
const passes = resolveEffectPasses({
|
||||
definition: effectDefinition,
|
||||
effectParams: this.params.effectParams,
|
||||
width: renderer.width,
|
||||
height: renderer.height,
|
||||
});
|
||||
if (passes.length === 0) {
|
||||
return;
|
||||
}
|
||||
const effectResult = webglEffectRenderer.applyEffect({
|
||||
source,
|
||||
width: renderer.width,
|
||||
height: renderer.height,
|
||||
passes,
|
||||
});
|
||||
|
||||
renderer.context.save();
|
||||
renderer.context.clearRect(0, 0, renderer.width, renderer.height);
|
||||
renderer.context.drawImage(
|
||||
effectResult,
|
||||
0,
|
||||
0,
|
||||
renderer.width,
|
||||
renderer.height,
|
||||
);
|
||||
renderer.context.restore();
|
||||
}
|
||||
}
|
||||
import type { CanvasRenderer } from "../canvas-renderer";
|
||||
import { effectsRegistry, resolveEffectPasses } from "@/lib/effects";
|
||||
import type { ParamValues } from "@/lib/params";
|
||||
import { BaseNode } from "./base-node";
|
||||
import { gpuRenderer } from "../gpu-renderer";
|
||||
|
||||
const TIME_EPSILON = 1e-6;
|
||||
|
||||
export type EffectLayerNodeParams = {
|
||||
effectType: string;
|
||||
effectParams: ParamValues;
|
||||
timeOffset: number;
|
||||
duration: number;
|
||||
};
|
||||
|
||||
function isInRange({
|
||||
time,
|
||||
timeOffset,
|
||||
duration,
|
||||
}: {
|
||||
time: number;
|
||||
timeOffset: number;
|
||||
duration: number;
|
||||
}): boolean {
|
||||
return (
|
||||
time >= timeOffset - TIME_EPSILON &&
|
||||
time < timeOffset + duration + TIME_EPSILON
|
||||
);
|
||||
}
|
||||
|
||||
// snapshots whatever is currently on the canvas, applies the effect, draws it back
|
||||
export class EffectLayerNode extends BaseNode<EffectLayerNodeParams> {
|
||||
async render({
|
||||
renderer,
|
||||
time,
|
||||
}: {
|
||||
renderer: CanvasRenderer;
|
||||
time: number;
|
||||
}): Promise<void> {
|
||||
if (
|
||||
!isInRange({
|
||||
time,
|
||||
timeOffset: this.params.timeOffset,
|
||||
duration: this.params.duration,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const source = renderer.context.canvas as CanvasImageSource;
|
||||
|
||||
const effectDefinition = effectsRegistry.get(this.params.effectType);
|
||||
|
||||
const passes = resolveEffectPasses({
|
||||
definition: effectDefinition,
|
||||
effectParams: this.params.effectParams,
|
||||
width: renderer.width,
|
||||
height: renderer.height,
|
||||
});
|
||||
if (passes.length === 0) {
|
||||
return;
|
||||
}
|
||||
const effectResult = gpuRenderer.applyEffect({
|
||||
source,
|
||||
width: renderer.width,
|
||||
height: renderer.height,
|
||||
passes,
|
||||
});
|
||||
|
||||
renderer.context.save();
|
||||
renderer.context.clearRect(0, 0, renderer.width, renderer.height);
|
||||
renderer.context.drawImage(
|
||||
effectResult,
|
||||
0,
|
||||
0,
|
||||
renderer.width,
|
||||
renderer.height,
|
||||
);
|
||||
renderer.context.restore();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
} from "@/lib/animation";
|
||||
import { resolveEffectParamsAtTime } from "@/lib/animation/effect-param-channel";
|
||||
import { effectsRegistry, resolveEffectPasses } from "@/lib/effects";
|
||||
import { webglEffectRenderer } from "../webgl/webgl-effect-renderer";
|
||||
import { gpuRenderer } from "../gpu-renderer";
|
||||
import { clamp } from "@/utils/math";
|
||||
|
||||
const TEXT_DECORATION_THICKNESS_RATIO = 0.07;
|
||||
@@ -272,7 +272,7 @@ export class TextNode extends BaseNode<TextNodeParams> {
|
||||
width: renderer.width,
|
||||
height: renderer.height,
|
||||
});
|
||||
currentSource = webglEffectRenderer.applyEffect({
|
||||
currentSource = gpuRenderer.applyEffect({
|
||||
source: currentSource,
|
||||
width: renderer.width,
|
||||
height: renderer.height,
|
||||
|
||||
@@ -16,7 +16,7 @@ import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
|
||||
import { effectsRegistry, resolveEffectPasses } from "@/lib/effects";
|
||||
import { masksRegistry } from "@/lib/masks";
|
||||
import { getSourceTimeAtClipTime } from "@/lib/retime";
|
||||
import { webglEffectRenderer } from "../webgl/webgl-effect-renderer";
|
||||
import { gpuRenderer } from "../gpu-renderer";
|
||||
import { applyMaskFeather } from "../mask-feather";
|
||||
|
||||
export interface VisualNodeParams {
|
||||
@@ -205,7 +205,7 @@ export abstract class VisualNode<
|
||||
width,
|
||||
height,
|
||||
});
|
||||
current = webglEffectRenderer.applyEffect({
|
||||
current = gpuRenderer.applyEffect({
|
||||
source: current,
|
||||
width: Math.round(width),
|
||||
height: Math.round(height),
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
import jfaInitShader from "@/lib/shaders/jfa-init.frag.glsl";
|
||||
import jfaStepShader from "@/lib/shaders/jfa-step.frag.glsl";
|
||||
import {
|
||||
compileProgram,
|
||||
createFramebufferTexture,
|
||||
setUniforms,
|
||||
drawFullscreenQuad,
|
||||
} from "./webgl-utils";
|
||||
|
||||
interface FBPair {
|
||||
texture: WebGLTexture;
|
||||
framebuffer: WebGLFramebuffer;
|
||||
}
|
||||
|
||||
function runPass({
|
||||
context,
|
||||
program,
|
||||
inputTexture,
|
||||
target,
|
||||
width,
|
||||
height,
|
||||
uniforms,
|
||||
extraBindings,
|
||||
}: {
|
||||
context: WebGLRenderingContext;
|
||||
program: WebGLProgram;
|
||||
inputTexture: WebGLTexture;
|
||||
target: WebGLFramebuffer | null;
|
||||
width: number;
|
||||
height: number;
|
||||
uniforms: Record<string, number | number[]>;
|
||||
extraBindings?: Array<{ unit: number; texture: WebGLTexture; name: string }>;
|
||||
}): void {
|
||||
context.bindFramebuffer(context.FRAMEBUFFER, target);
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: WebGL API method, not a React hook
|
||||
context.useProgram(program);
|
||||
|
||||
context.activeTexture(context.TEXTURE0);
|
||||
context.bindTexture(context.TEXTURE_2D, inputTexture);
|
||||
const uTexLoc = context.getUniformLocation(program, "u_texture");
|
||||
if (uTexLoc) context.uniform1i(uTexLoc, 0);
|
||||
|
||||
if (extraBindings) {
|
||||
for (const binding of extraBindings) {
|
||||
context.activeTexture(context.TEXTURE0 + binding.unit);
|
||||
context.bindTexture(context.TEXTURE_2D, binding.texture);
|
||||
const loc = context.getUniformLocation(program, binding.name);
|
||||
if (loc) context.uniform1i(loc, binding.unit);
|
||||
}
|
||||
}
|
||||
|
||||
setUniforms({
|
||||
context,
|
||||
program,
|
||||
uniforms: { ...uniforms, u_resolution: [width, height] },
|
||||
});
|
||||
drawFullscreenQuad({ context, program, width, height });
|
||||
}
|
||||
|
||||
function runJFA({
|
||||
context,
|
||||
programCache,
|
||||
sourceTexture,
|
||||
width,
|
||||
height,
|
||||
isInverted,
|
||||
}: {
|
||||
context: WebGLRenderingContext;
|
||||
programCache: Map<string, WebGLProgram>;
|
||||
sourceTexture: WebGLTexture;
|
||||
width: number;
|
||||
height: number;
|
||||
isInverted: boolean;
|
||||
}): {
|
||||
resultTexture: WebGLTexture;
|
||||
resultFB: WebGLFramebuffer;
|
||||
tempFBs: FBPair[];
|
||||
} {
|
||||
const numSteps = Math.ceil(Math.log2(Math.max(width, height)));
|
||||
|
||||
const fbA = createFramebufferTexture({ context, width, height });
|
||||
const fbB = createFramebufferTexture({ context, width, height });
|
||||
|
||||
const initProgram = compileProgram({
|
||||
context,
|
||||
fragmentShaderSource: jfaInitShader,
|
||||
programCache,
|
||||
});
|
||||
runPass({
|
||||
context,
|
||||
program: initProgram,
|
||||
inputTexture: sourceTexture,
|
||||
target: fbA.framebuffer,
|
||||
width,
|
||||
height,
|
||||
uniforms: { u_invert: isInverted ? 1.0 : 0.0 },
|
||||
});
|
||||
|
||||
const stepProgram = compileProgram({
|
||||
context,
|
||||
fragmentShaderSource: jfaStepShader,
|
||||
programCache,
|
||||
});
|
||||
|
||||
let readFB = fbA;
|
||||
let writeFB = fbB;
|
||||
|
||||
for (let i = numSteps - 1; i >= 0; i--) {
|
||||
const stepSize = 2 ** i;
|
||||
runPass({
|
||||
context,
|
||||
program: stepProgram,
|
||||
inputTexture: readFB.texture,
|
||||
target: writeFB.framebuffer,
|
||||
width,
|
||||
height,
|
||||
uniforms: { u_step_size: stepSize },
|
||||
});
|
||||
const tmp = readFB;
|
||||
readFB = writeFB;
|
||||
writeFB = tmp;
|
||||
}
|
||||
|
||||
return {
|
||||
resultTexture: readFB.texture,
|
||||
resultFB: readFB.framebuffer,
|
||||
tempFBs: [writeFB],
|
||||
};
|
||||
}
|
||||
|
||||
function cleanupJFAResult({
|
||||
context,
|
||||
result,
|
||||
}: {
|
||||
context: WebGLRenderingContext;
|
||||
result: {
|
||||
resultTexture: WebGLTexture;
|
||||
resultFB: WebGLFramebuffer;
|
||||
tempFBs: FBPair[];
|
||||
};
|
||||
}): void {
|
||||
context.deleteTexture(result.resultTexture);
|
||||
context.deleteFramebuffer(result.resultFB);
|
||||
for (const fb of result.tempFBs) {
|
||||
context.deleteTexture(fb.texture);
|
||||
context.deleteFramebuffer(fb.framebuffer);
|
||||
}
|
||||
}
|
||||
|
||||
export { runPass };
|
||||
|
||||
export function computeSignedDistanceField({
|
||||
context,
|
||||
programCache,
|
||||
sourceTexture,
|
||||
width,
|
||||
height,
|
||||
}: {
|
||||
context: WebGLRenderingContext;
|
||||
programCache: Map<string, WebGLProgram>;
|
||||
sourceTexture: WebGLTexture;
|
||||
width: number;
|
||||
height: number;
|
||||
}): {
|
||||
insideTexture: WebGLTexture;
|
||||
outsideTexture: WebGLTexture;
|
||||
cleanup: () => void;
|
||||
} {
|
||||
const inside = runJFA({
|
||||
context,
|
||||
programCache,
|
||||
sourceTexture,
|
||||
width,
|
||||
height,
|
||||
isInverted: false,
|
||||
});
|
||||
const outside = runJFA({
|
||||
context,
|
||||
programCache,
|
||||
sourceTexture,
|
||||
width,
|
||||
height,
|
||||
isInverted: true,
|
||||
});
|
||||
|
||||
return {
|
||||
insideTexture: inside.resultTexture,
|
||||
outsideTexture: outside.resultTexture,
|
||||
cleanup: () => {
|
||||
cleanupJFAResult({ context, result: inside });
|
||||
cleanupJFAResult({ context, result: outside });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { createOffscreenCanvas } from "../canvas-utils";
|
||||
|
||||
let gl: WebGLRenderingContext | null = null;
|
||||
let webglCanvas: OffscreenCanvas | HTMLCanvasElement | null = null;
|
||||
const programCache = new Map<string, WebGLProgram>();
|
||||
|
||||
export function getWebGLContext({
|
||||
width,
|
||||
height,
|
||||
}: {
|
||||
width: number;
|
||||
height: number;
|
||||
}): {
|
||||
context: WebGLRenderingContext;
|
||||
programCache: Map<string, WebGLProgram>;
|
||||
} {
|
||||
if (!webglCanvas) {
|
||||
webglCanvas = createOffscreenCanvas({ width, height });
|
||||
gl = webglCanvas.getContext("webgl", {
|
||||
premultipliedAlpha: false,
|
||||
}) as WebGLRenderingContext | null;
|
||||
if (!gl) throw new Error("WebGL not supported");
|
||||
}
|
||||
if (webglCanvas.width !== width || webglCanvas.height !== height) {
|
||||
webglCanvas.width = width;
|
||||
webglCanvas.height = height;
|
||||
}
|
||||
if (!gl) throw new Error("WebGL context lost");
|
||||
return { context: gl, programCache };
|
||||
}
|
||||
|
||||
export function readResult({
|
||||
width,
|
||||
height,
|
||||
}: {
|
||||
width: number;
|
||||
height: number;
|
||||
}): OffscreenCanvas | HTMLCanvasElement {
|
||||
if (!webglCanvas) throw new Error("WebGL canvas not initialized");
|
||||
const outputCanvas = createOffscreenCanvas({ width, height });
|
||||
const outputCtx = outputCanvas.getContext("2d") as
|
||||
| CanvasRenderingContext2D
|
||||
| OffscreenCanvasRenderingContext2D
|
||||
| null;
|
||||
if (outputCtx) {
|
||||
outputCtx.drawImage(webglCanvas, 0, 0, width, height);
|
||||
}
|
||||
return outputCanvas;
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { getWebGLContext, readResult } from "./webgl-context";
|
||||
import { applyMultiPassEffect } from "./webgl-utils";
|
||||
import type { EffectPassData } from "./webgl-utils";
|
||||
|
||||
export interface ApplyEffectParams {
|
||||
source: CanvasImageSource;
|
||||
width: number;
|
||||
height: number;
|
||||
passes: EffectPassData[];
|
||||
}
|
||||
|
||||
function applyEffect({
|
||||
source,
|
||||
width,
|
||||
height,
|
||||
passes,
|
||||
}: ApplyEffectParams): CanvasImageSource {
|
||||
if (passes.length === 0) {
|
||||
return source;
|
||||
}
|
||||
const { context, programCache } = getWebGLContext({ width, height });
|
||||
applyMultiPassEffect({
|
||||
context,
|
||||
source,
|
||||
width,
|
||||
height,
|
||||
passes,
|
||||
programCache,
|
||||
});
|
||||
return readResult({ width, height });
|
||||
}
|
||||
|
||||
export const webglEffectRenderer = {
|
||||
applyEffect,
|
||||
};
|
||||
@@ -1,298 +0,0 @@
|
||||
import VERTEX_SHADER_SOURCE from "@/lib/effects/effect.vert.glsl";
|
||||
|
||||
export interface EffectPassData {
|
||||
fragmentShader: string;
|
||||
uniforms: Record<string, number | number[]>;
|
||||
}
|
||||
|
||||
export const QUAD_POSITIONS = new Float32Array([
|
||||
-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1,
|
||||
]);
|
||||
|
||||
export function compileProgram({
|
||||
context,
|
||||
fragmentShaderSource,
|
||||
programCache,
|
||||
}: {
|
||||
context: WebGLRenderingContext;
|
||||
fragmentShaderSource: string;
|
||||
programCache: Map<string, WebGLProgram>;
|
||||
}): WebGLProgram {
|
||||
const cached = programCache.get(fragmentShaderSource);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const vertexShader = compileShader({
|
||||
context,
|
||||
source: VERTEX_SHADER_SOURCE,
|
||||
type: context.VERTEX_SHADER,
|
||||
});
|
||||
const fragmentShader = compileShader({
|
||||
context,
|
||||
source: fragmentShaderSource,
|
||||
type: context.FRAGMENT_SHADER,
|
||||
});
|
||||
const program = context.createProgram();
|
||||
if (!program) {
|
||||
throw new Error("Failed to create WebGL program");
|
||||
}
|
||||
context.attachShader(program, vertexShader);
|
||||
context.attachShader(program, fragmentShader);
|
||||
context.linkProgram(program);
|
||||
if (!context.getProgramParameter(program, context.LINK_STATUS)) {
|
||||
const info = context.getProgramInfoLog(program);
|
||||
context.deleteProgram(program);
|
||||
throw new Error(`WebGL program link failed: ${info}`);
|
||||
}
|
||||
context.deleteShader(vertexShader);
|
||||
context.deleteShader(fragmentShader);
|
||||
programCache.set(fragmentShaderSource, program);
|
||||
return program;
|
||||
}
|
||||
|
||||
export function compileShader({
|
||||
context,
|
||||
source,
|
||||
type,
|
||||
}: {
|
||||
context: WebGLRenderingContext;
|
||||
source: string;
|
||||
type: number;
|
||||
}): WebGLShader {
|
||||
const shader = context.createShader(type);
|
||||
if (!shader) {
|
||||
throw new Error("Failed to create WebGL shader");
|
||||
}
|
||||
context.shaderSource(shader, source);
|
||||
context.compileShader(shader);
|
||||
if (!context.getShaderParameter(shader, context.COMPILE_STATUS)) {
|
||||
const info = context.getShaderInfoLog(shader);
|
||||
context.deleteShader(shader);
|
||||
throw new Error(`WebGL shader compile failed: ${info}`);
|
||||
}
|
||||
return shader;
|
||||
}
|
||||
|
||||
export function createTexture({
|
||||
context,
|
||||
source,
|
||||
}: {
|
||||
context: WebGLRenderingContext;
|
||||
source: CanvasImageSource;
|
||||
}): WebGLTexture {
|
||||
const texture = context.createTexture();
|
||||
if (!texture) {
|
||||
throw new Error("Failed to create WebGL texture");
|
||||
}
|
||||
context.activeTexture(context.TEXTURE0);
|
||||
context.bindTexture(context.TEXTURE_2D, texture);
|
||||
context.pixelStorei(context.UNPACK_FLIP_Y_WEBGL, 1);
|
||||
context.texParameteri(
|
||||
context.TEXTURE_2D,
|
||||
context.TEXTURE_WRAP_S,
|
||||
context.CLAMP_TO_EDGE,
|
||||
);
|
||||
context.texParameteri(
|
||||
context.TEXTURE_2D,
|
||||
context.TEXTURE_WRAP_T,
|
||||
context.CLAMP_TO_EDGE,
|
||||
);
|
||||
context.texParameteri(
|
||||
context.TEXTURE_2D,
|
||||
context.TEXTURE_MIN_FILTER,
|
||||
context.LINEAR,
|
||||
);
|
||||
context.texParameteri(
|
||||
context.TEXTURE_2D,
|
||||
context.TEXTURE_MAG_FILTER,
|
||||
context.LINEAR,
|
||||
);
|
||||
context.texImage2D(
|
||||
context.TEXTURE_2D,
|
||||
0,
|
||||
context.RGBA,
|
||||
context.RGBA,
|
||||
context.UNSIGNED_BYTE,
|
||||
source as TexImageSource,
|
||||
);
|
||||
return texture;
|
||||
}
|
||||
|
||||
export function setUniforms({
|
||||
context,
|
||||
program,
|
||||
uniforms,
|
||||
}: {
|
||||
context: WebGLRenderingContext;
|
||||
program: WebGLProgram;
|
||||
uniforms: Record<string, number | number[]>;
|
||||
}): void {
|
||||
for (const [name, value] of Object.entries(uniforms)) {
|
||||
const location = context.getUniformLocation(program, name);
|
||||
if (location === null) continue;
|
||||
|
||||
if (typeof value === "number") {
|
||||
context.uniform1f(location, value);
|
||||
} else if (Array.isArray(value)) {
|
||||
if (value.length === 2) {
|
||||
context.uniform2fv(location, new Float32Array(value));
|
||||
} else if (value.length === 3) {
|
||||
context.uniform3fv(location, new Float32Array(value));
|
||||
} else if (value.length === 4) {
|
||||
context.uniform4fv(location, new Float32Array(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function drawFullscreenQuad({
|
||||
context,
|
||||
program,
|
||||
width,
|
||||
height,
|
||||
}: {
|
||||
context: WebGLRenderingContext;
|
||||
program: WebGLProgram;
|
||||
width: number;
|
||||
height: number;
|
||||
}): void {
|
||||
const positionLocation = context.getAttribLocation(program, "a_position");
|
||||
const buffer = context.createBuffer();
|
||||
context.bindBuffer(context.ARRAY_BUFFER, buffer);
|
||||
context.bufferData(context.ARRAY_BUFFER, QUAD_POSITIONS, context.STATIC_DRAW);
|
||||
context.enableVertexAttribArray(positionLocation);
|
||||
context.vertexAttribPointer(positionLocation, 2, context.FLOAT, false, 0, 0);
|
||||
|
||||
context.viewport(0, 0, width, height);
|
||||
context.clearColor(0, 0, 0, 0);
|
||||
context.clear(context.COLOR_BUFFER_BIT);
|
||||
context.drawArrays(context.TRIANGLES, 0, 6);
|
||||
}
|
||||
|
||||
export function createFramebufferTexture({
|
||||
context,
|
||||
width,
|
||||
height,
|
||||
}: {
|
||||
context: WebGLRenderingContext;
|
||||
width: number;
|
||||
height: number;
|
||||
}): { texture: WebGLTexture; framebuffer: WebGLFramebuffer } {
|
||||
const texture = context.createTexture();
|
||||
if (!texture) throw new Error("Failed to create framebuffer texture");
|
||||
context.bindTexture(context.TEXTURE_2D, texture);
|
||||
context.texImage2D(
|
||||
context.TEXTURE_2D,
|
||||
0,
|
||||
context.RGBA,
|
||||
width,
|
||||
height,
|
||||
0,
|
||||
context.RGBA,
|
||||
context.UNSIGNED_BYTE,
|
||||
null,
|
||||
);
|
||||
context.texParameteri(
|
||||
context.TEXTURE_2D,
|
||||
context.TEXTURE_WRAP_S,
|
||||
context.CLAMP_TO_EDGE,
|
||||
);
|
||||
context.texParameteri(
|
||||
context.TEXTURE_2D,
|
||||
context.TEXTURE_WRAP_T,
|
||||
context.CLAMP_TO_EDGE,
|
||||
);
|
||||
context.texParameteri(
|
||||
context.TEXTURE_2D,
|
||||
context.TEXTURE_MIN_FILTER,
|
||||
context.LINEAR,
|
||||
);
|
||||
context.texParameteri(
|
||||
context.TEXTURE_2D,
|
||||
context.TEXTURE_MAG_FILTER,
|
||||
context.LINEAR,
|
||||
);
|
||||
context.bindTexture(context.TEXTURE_2D, null);
|
||||
|
||||
const framebuffer = context.createFramebuffer();
|
||||
if (!framebuffer) throw new Error("Failed to create framebuffer");
|
||||
context.bindFramebuffer(context.FRAMEBUFFER, framebuffer);
|
||||
context.framebufferTexture2D(
|
||||
context.FRAMEBUFFER,
|
||||
context.COLOR_ATTACHMENT0,
|
||||
context.TEXTURE_2D,
|
||||
texture,
|
||||
0,
|
||||
);
|
||||
context.bindFramebuffer(context.FRAMEBUFFER, null);
|
||||
|
||||
return { texture, framebuffer };
|
||||
}
|
||||
|
||||
export function applyMultiPassEffect({
|
||||
context,
|
||||
source,
|
||||
width,
|
||||
height,
|
||||
passes,
|
||||
programCache,
|
||||
}: {
|
||||
context: WebGLRenderingContext;
|
||||
source: CanvasImageSource;
|
||||
width: number;
|
||||
height: number;
|
||||
passes: EffectPassData[];
|
||||
programCache: Map<string, WebGLProgram>;
|
||||
}): void {
|
||||
const sourceTexture = createTexture({ context, source });
|
||||
let currentTexture: WebGLTexture = sourceTexture;
|
||||
|
||||
const intermediates: Array<{
|
||||
texture: WebGLTexture;
|
||||
framebuffer: WebGLFramebuffer;
|
||||
}> = [];
|
||||
for (let i = 0; i < passes.length - 1; i++) {
|
||||
intermediates.push(createFramebufferTexture({ context, width, height }));
|
||||
}
|
||||
|
||||
for (let i = 0; i < passes.length; i++) {
|
||||
const pass = passes[i];
|
||||
const program = compileProgram({
|
||||
context,
|
||||
fragmentShaderSource: pass.fragmentShader,
|
||||
programCache,
|
||||
});
|
||||
const isLastPass = i === passes.length - 1;
|
||||
const targetFramebuffer = isLastPass ? null : intermediates[i].framebuffer;
|
||||
|
||||
context.bindFramebuffer(context.FRAMEBUFFER, targetFramebuffer);
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: WebGL API method, not a React hook
|
||||
context.useProgram(program);
|
||||
context.activeTexture(context.TEXTURE0);
|
||||
context.bindTexture(context.TEXTURE_2D, currentTexture);
|
||||
|
||||
const uTextureLocation = context.getUniformLocation(program, "u_texture");
|
||||
if (uTextureLocation) {
|
||||
context.uniform1i(uTextureLocation, 0);
|
||||
}
|
||||
|
||||
setUniforms({
|
||||
context,
|
||||
program,
|
||||
uniforms: { ...pass.uniforms, u_resolution: [width, height] },
|
||||
});
|
||||
drawFullscreenQuad({ context, program, width, height });
|
||||
|
||||
if (!isLastPass) {
|
||||
currentTexture = intermediates[i].texture;
|
||||
}
|
||||
}
|
||||
|
||||
context.deleteTexture(sourceTexture);
|
||||
for (const intermediate of intermediates) {
|
||||
context.deleteTexture(intermediate.texture);
|
||||
context.deleteFramebuffer(intermediate.framebuffer);
|
||||
}
|
||||
context.bindTexture(context.TEXTURE_2D, null);
|
||||
context.bindFramebuffer(context.FRAMEBUFFER, null);
|
||||
}
|
||||
Reference in New Issue
Block a user