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:
@@ -3,14 +3,6 @@ import { withBotId } from "botid/next/config";
|
||||
import { withContentCollections } from "@content-collections/next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
turbopack: {
|
||||
rules: {
|
||||
"*.glsl": {
|
||||
loaders: [require.resolve("raw-loader")],
|
||||
as: "*.js",
|
||||
},
|
||||
},
|
||||
},
|
||||
compiler: {
|
||||
removeConsole: process.env.NODE_ENV === "production",
|
||||
},
|
||||
|
||||
@@ -94,7 +94,6 @@
|
||||
"dotenv": "^16.5.0",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"postcss": "^8",
|
||||
"raw-loader": "^4.0.2",
|
||||
"sharp": "^0.34.5",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"typescript": "^5.8.3",
|
||||
|
||||
@@ -1,133 +1,135 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { EditorCore } from "@/core";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useKeybindingsListener } from "@/hooks/use-keybindings";
|
||||
import { useKeybindingsStore } from "@/stores/keybindings-store";
|
||||
import { useEditorActions } from "@/hooks/actions/use-editor-actions";
|
||||
import { loadFontAtlas } from "@/lib/fonts/google-fonts";
|
||||
|
||||
interface EditorProviderProps {
|
||||
projectId: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function EditorProvider({ projectId, children }: EditorProviderProps) {
|
||||
const activeProject = useEditor((e) => e.project.getActiveOrNull());
|
||||
const router = useRouter();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { setLoadingProject } = useKeybindingsStore();
|
||||
|
||||
useEffect(() => {
|
||||
setLoadingProject(isLoading);
|
||||
}, [isLoading, setLoadingProject]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const editor = EditorCore.getInstance();
|
||||
|
||||
const loadProject = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await editor.project.loadProject({ id: projectId });
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
setIsLoading(false);
|
||||
loadFontAtlas();
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
|
||||
const isNotFound =
|
||||
err instanceof Error &&
|
||||
(err.message.includes("not found") ||
|
||||
err.message.includes("does not exist"));
|
||||
|
||||
if (isNotFound) {
|
||||
try {
|
||||
const newProjectId = await editor.project.createNewProject({
|
||||
name: "Untitled Project",
|
||||
});
|
||||
router.replace(`/editor/${newProjectId}`);
|
||||
} catch (_createErr) {
|
||||
setError("Failed to create project");
|
||||
setIsLoading(false);
|
||||
}
|
||||
} else {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to load project",
|
||||
);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadProject();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectId, router]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-background flex h-screen w-screen items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<p className="text-destructive text-sm">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bg-background flex h-screen w-screen items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Loader2 className="text-muted-foreground size-8 animate-spin" />
|
||||
<p className="text-muted-foreground text-sm">Loading project...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!activeProject) {
|
||||
return (
|
||||
<div className="bg-background flex h-screen w-screen items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Loader2 className="text-muted-foreground size-8 animate-spin" />
|
||||
<p className="text-muted-foreground text-sm">Exiting project...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<EditorRuntimeBindings />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function EditorRuntimeBindings() {
|
||||
const editor = useEditor();
|
||||
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
if (!editor.save.getIsDirty()) return;
|
||||
event.preventDefault();
|
||||
(event as unknown as { returnValue: string }).returnValue = "";
|
||||
};
|
||||
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
}, [editor]);
|
||||
|
||||
useEditorActions();
|
||||
useKeybindingsListener();
|
||||
return null;
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { EditorCore } from "@/core";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useKeybindingsListener } from "@/hooks/use-keybindings";
|
||||
import { useKeybindingsStore } from "@/stores/keybindings-store";
|
||||
import { useEditorActions } from "@/hooks/actions/use-editor-actions";
|
||||
import { loadFontAtlas } from "@/lib/fonts/google-fonts";
|
||||
import { initializeGpuRenderer } from "@/services/renderer/gpu-renderer";
|
||||
|
||||
interface EditorProviderProps {
|
||||
projectId: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function EditorProvider({ projectId, children }: EditorProviderProps) {
|
||||
const activeProject = useEditor((e) => e.project.getActiveOrNull());
|
||||
const router = useRouter();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { setLoadingProject } = useKeybindingsStore();
|
||||
|
||||
useEffect(() => {
|
||||
setLoadingProject(isLoading);
|
||||
}, [isLoading, setLoadingProject]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const editor = EditorCore.getInstance();
|
||||
|
||||
const loadProject = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await initializeGpuRenderer();
|
||||
await editor.project.loadProject({ id: projectId });
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
setIsLoading(false);
|
||||
loadFontAtlas();
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
|
||||
const isNotFound =
|
||||
err instanceof Error &&
|
||||
(err.message.includes("not found") ||
|
||||
err.message.includes("does not exist"));
|
||||
|
||||
if (isNotFound) {
|
||||
try {
|
||||
const newProjectId = await editor.project.createNewProject({
|
||||
name: "Untitled Project",
|
||||
});
|
||||
router.replace(`/editor/${newProjectId}`);
|
||||
} catch (_createErr) {
|
||||
setError("Failed to create project");
|
||||
setIsLoading(false);
|
||||
}
|
||||
} else {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to load project",
|
||||
);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadProject();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectId, router]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-background flex h-screen w-screen items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<p className="text-destructive text-sm">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bg-background flex h-screen w-screen items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Loader2 className="text-muted-foreground size-8 animate-spin" />
|
||||
<p className="text-muted-foreground text-sm">Loading project...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!activeProject) {
|
||||
return (
|
||||
<div className="bg-background flex h-screen w-screen items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Loader2 className="text-muted-foreground size-8 animate-spin" />
|
||||
<p className="text-muted-foreground text-sm">Exiting project...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<EditorRuntimeBindings />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function EditorRuntimeBindings() {
|
||||
const editor = useEditor();
|
||||
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
if (!editor.save.getIsDirty()) return;
|
||||
event.preventDefault();
|
||||
(event as unknown as { returnValue: string }).returnValue = "";
|
||||
};
|
||||
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
}, [editor]);
|
||||
|
||||
useEditorActions();
|
||||
useKeybindingsListener();
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
precision mediump float;
|
||||
|
||||
uniform sampler2D u_texture;
|
||||
uniform vec2 u_resolution;
|
||||
uniform float u_sigma;
|
||||
uniform float u_step;
|
||||
uniform vec2 u_direction;
|
||||
|
||||
varying vec2 v_texCoord;
|
||||
|
||||
void main() {
|
||||
vec2 texelSize = 1.0 / u_resolution;
|
||||
|
||||
vec4 color = vec4(0.0);
|
||||
float totalWeight = 0.0;
|
||||
|
||||
for (int i = -30; i <= 30; i++) {
|
||||
float pos = float(i) * u_step;
|
||||
float weight = exp(-(pos * pos) / (2.0 * u_sigma * u_sigma));
|
||||
color += texture2D(u_texture, v_texCoord + texelSize * u_direction * pos) * weight;
|
||||
totalWeight += weight;
|
||||
}
|
||||
|
||||
gl_FragColor = color / totalWeight;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { EffectDefinition, ResolvedEffectPass } from "@/lib/effects/types";
|
||||
import blurFragmentShader from "./blur.frag.glsl";
|
||||
import type { EffectDefinition, EffectPass } from "@/lib/effects/types";
|
||||
|
||||
export const GAUSSIAN_BLUR_SHADER = "gaussian-blur";
|
||||
|
||||
const MAX_SINGLE_PASS_SIGMA = 10;
|
||||
const MAX_STEP = 4;
|
||||
@@ -17,7 +18,7 @@ export function buildGaussianBlurPasses({
|
||||
}: {
|
||||
sigmaX: number;
|
||||
sigmaY: number;
|
||||
}): ResolvedEffectPass[] {
|
||||
}): EffectPass[] {
|
||||
const maxSigma = Math.max(sigmaX, sigmaY);
|
||||
if (maxSigma < 0.001) return [];
|
||||
|
||||
@@ -36,10 +37,10 @@ export function buildGaussianBlurPasses({
|
||||
const stepX = Math.max(1, perPassSigmaX / MAX_SINGLE_PASS_SIGMA);
|
||||
const stepY = Math.max(1, perPassSigmaY / MAX_SINGLE_PASS_SIGMA);
|
||||
|
||||
const passes: ResolvedEffectPass[] = [];
|
||||
const passes: EffectPass[] = [];
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
passes.push({
|
||||
fragmentShader: blurFragmentShader,
|
||||
shader: GAUSSIAN_BLUR_SHADER,
|
||||
uniforms: {
|
||||
u_sigma: perPassSigmaX,
|
||||
u_step: stepX,
|
||||
@@ -47,7 +48,7 @@ export function buildGaussianBlurPasses({
|
||||
},
|
||||
});
|
||||
passes.push({
|
||||
fragmentShader: blurFragmentShader,
|
||||
shader: GAUSSIAN_BLUR_SHADER,
|
||||
uniforms: {
|
||||
u_sigma: perPassSigmaY,
|
||||
u_step: stepY,
|
||||
@@ -83,10 +84,9 @@ export const blurEffectDefinition: EffectDefinition = {
|
||||
},
|
||||
],
|
||||
renderer: {
|
||||
type: "webgl",
|
||||
passes: [
|
||||
{
|
||||
fragmentShader: blurFragmentShader,
|
||||
shader: GAUSSIAN_BLUR_SHADER,
|
||||
uniforms: ({ effectParams, width }) => ({
|
||||
u_sigma: Math.max(intensityToSigma(parseIntensity(effectParams), width, 1920), 0.001),
|
||||
u_step: 1,
|
||||
@@ -94,7 +94,7 @@ export const blurEffectDefinition: EffectDefinition = {
|
||||
}),
|
||||
},
|
||||
{
|
||||
fragmentShader: blurFragmentShader,
|
||||
shader: GAUSSIAN_BLUR_SHADER,
|
||||
uniforms: ({ effectParams, height }) => ({
|
||||
u_sigma: Math.max(intensityToSigma(parseIntensity(effectParams), height, 1080), 0.001),
|
||||
u_step: 1,
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
attribute vec2 a_position;
|
||||
varying vec2 v_texCoord;
|
||||
|
||||
void main() {
|
||||
v_texCoord = a_position * 0.5 + 0.5;
|
||||
gl_Position = vec4(a_position, 0.0, 1.0);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { generateUUID } from "@/utils/id";
|
||||
import { buildDefaultParamValues } from "@/lib/registry";
|
||||
import { effectsRegistry } from "./registry";
|
||||
import type { ParamValues } from "@/lib/params";
|
||||
import type { Effect, EffectDefinition, ResolvedEffectPass } from "@/lib/effects/types";
|
||||
import type { Effect, EffectDefinition, EffectPass } from "@/lib/effects/types";
|
||||
import { VISUAL_ELEMENT_TYPES } from "@/lib/timeline";
|
||||
|
||||
export { effectsRegistry } from "./registry";
|
||||
@@ -18,12 +18,12 @@ export function resolveEffectPasses({
|
||||
effectParams: ParamValues;
|
||||
width: number;
|
||||
height: number;
|
||||
}): ResolvedEffectPass[] {
|
||||
}): EffectPass[] {
|
||||
if (definition.renderer.buildPasses) {
|
||||
return definition.renderer.buildPasses({ effectParams, width, height });
|
||||
}
|
||||
return definition.renderer.passes.map((pass) => ({
|
||||
fragmentShader: pass.fragmentShader,
|
||||
shader: pass.shader,
|
||||
uniforms: pass.uniforms({ effectParams, width, height }),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1,42 +1,41 @@
|
||||
import type { ParamDefinition, ParamValues } from "@/lib/params";
|
||||
|
||||
export interface Effect {
|
||||
id: string;
|
||||
type: string;
|
||||
params: ParamValues;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface ResolvedEffectPass {
|
||||
fragmentShader: string;
|
||||
uniforms: Record<string, number | number[]>;
|
||||
}
|
||||
|
||||
export interface WebGLEffectPass {
|
||||
fragmentShader: string;
|
||||
uniforms(params: {
|
||||
effectParams: ParamValues;
|
||||
width: number;
|
||||
height: number;
|
||||
}): Record<string, number | number[]>;
|
||||
}
|
||||
|
||||
export interface WebGLEffectRenderer {
|
||||
type: "webgl";
|
||||
passes: WebGLEffectPass[];
|
||||
buildPasses?: (params: {
|
||||
effectParams: ParamValues;
|
||||
width: number;
|
||||
height: number;
|
||||
}) => ResolvedEffectPass[];
|
||||
}
|
||||
|
||||
export type EffectRenderer = WebGLEffectRenderer;
|
||||
|
||||
export interface EffectDefinition {
|
||||
type: string;
|
||||
name: string;
|
||||
keywords: string[];
|
||||
params: ParamDefinition[];
|
||||
renderer: EffectRenderer;
|
||||
}
|
||||
import type { ParamDefinition, ParamValues } from "@/lib/params";
|
||||
|
||||
export interface Effect {
|
||||
id: string;
|
||||
type: string;
|
||||
params: ParamValues;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export type EffectUniformValue = number | number[];
|
||||
|
||||
export interface EffectPass {
|
||||
shader: string;
|
||||
uniforms: Record<string, EffectUniformValue>;
|
||||
}
|
||||
|
||||
export interface EffectPassTemplate {
|
||||
shader: string;
|
||||
uniforms(params: {
|
||||
effectParams: ParamValues;
|
||||
width: number;
|
||||
height: number;
|
||||
}): Record<string, EffectUniformValue>;
|
||||
}
|
||||
|
||||
export interface EffectRendererConfig {
|
||||
passes: EffectPassTemplate[];
|
||||
buildPasses?: (params: {
|
||||
effectParams: ParamValues;
|
||||
width: number;
|
||||
height: number;
|
||||
}) => EffectPass[];
|
||||
}
|
||||
|
||||
export interface EffectDefinition {
|
||||
type: string;
|
||||
name: string;
|
||||
keywords: string[];
|
||||
params: ParamDefinition[];
|
||||
renderer: EffectRendererConfig;
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
precision mediump float;
|
||||
|
||||
uniform sampler2D u_texture;
|
||||
uniform sampler2D u_jfa_outside;
|
||||
uniform vec2 u_resolution;
|
||||
uniform float u_feather_half;
|
||||
|
||||
varying vec2 v_texCoord;
|
||||
|
||||
vec2 decodeSeed(vec4 encoded) {
|
||||
float x = floor(encoded.r * 255.0 + 0.5) * 256.0 + floor(encoded.g * 255.0 + 0.5);
|
||||
float y = floor(encoded.b * 255.0 + 0.5) * 256.0 + floor(encoded.a * 255.0 + 0.5);
|
||||
return vec2(x, y);
|
||||
}
|
||||
|
||||
bool isNoSeed(vec4 encoded) {
|
||||
return encoded.r > 0.99 && encoded.g > 0.99 && encoded.b > 0.99 && encoded.a > 0.99;
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 pixelCoord = floor(v_texCoord * u_resolution);
|
||||
|
||||
vec4 insideEncoded = texture2D(u_texture, v_texCoord);
|
||||
vec4 outsideEncoded = texture2D(u_jfa_outside, v_texCoord);
|
||||
|
||||
bool hasInside = !isNoSeed(insideEncoded);
|
||||
bool hasOutside = !isNoSeed(outsideEncoded);
|
||||
|
||||
float distToInside = hasInside ? distance(pixelCoord, decodeSeed(insideEncoded)) : 1e5;
|
||||
float distToOutside = hasOutside ? distance(pixelCoord, decodeSeed(outsideEncoded)) : 1e5;
|
||||
|
||||
float signedDist = distToOutside - distToInside;
|
||||
float alpha = smoothstep(-u_feather_half, u_feather_half, signedDist);
|
||||
|
||||
gl_FragColor = vec4(alpha, alpha, alpha, alpha);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
precision mediump float;
|
||||
|
||||
uniform sampler2D u_texture;
|
||||
uniform vec2 u_resolution;
|
||||
uniform float u_invert;
|
||||
|
||||
varying vec2 v_texCoord;
|
||||
|
||||
void main() {
|
||||
float mask = texture2D(u_texture, v_texCoord).r;
|
||||
bool isSeed = u_invert > 0.5 ? mask < 0.5 : mask > 0.5;
|
||||
|
||||
if (isSeed) {
|
||||
vec2 pixelCoord = floor(v_texCoord * u_resolution);
|
||||
float x = pixelCoord.x;
|
||||
float y = pixelCoord.y;
|
||||
float xHi = floor(x / 256.0);
|
||||
float xLo = x - xHi * 256.0;
|
||||
float yHi = floor(y / 256.0);
|
||||
float yLo = y - yHi * 256.0;
|
||||
gl_FragColor = vec4(xHi / 255.0, xLo / 255.0, yHi / 255.0, yLo / 255.0);
|
||||
} else {
|
||||
gl_FragColor = vec4(1.0, 1.0, 1.0, 1.0);
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
precision mediump float;
|
||||
|
||||
uniform sampler2D u_texture;
|
||||
uniform vec2 u_resolution;
|
||||
uniform float u_step_size;
|
||||
|
||||
varying vec2 v_texCoord;
|
||||
|
||||
vec2 decodeSeed(vec4 encoded) {
|
||||
float x = floor(encoded.r * 255.0 + 0.5) * 256.0 + floor(encoded.g * 255.0 + 0.5);
|
||||
float y = floor(encoded.b * 255.0 + 0.5) * 256.0 + floor(encoded.a * 255.0 + 0.5);
|
||||
return vec2(x, y);
|
||||
}
|
||||
|
||||
vec4 encodeSeed(vec2 seed) {
|
||||
float xHi = floor(seed.x / 256.0);
|
||||
float xLo = seed.x - xHi * 256.0;
|
||||
float yHi = floor(seed.y / 256.0);
|
||||
float yLo = seed.y - yHi * 256.0;
|
||||
return vec4(xHi / 255.0, xLo / 255.0, yHi / 255.0, yLo / 255.0);
|
||||
}
|
||||
|
||||
bool isNoSeed(vec4 encoded) {
|
||||
return encoded.r > 0.99 && encoded.g > 0.99 && encoded.b > 0.99 && encoded.a > 0.99;
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 pixelCoord = floor(v_texCoord * u_resolution);
|
||||
vec2 texelSize = 1.0 / u_resolution;
|
||||
|
||||
float bestDist = 1e10;
|
||||
vec2 bestSeed = vec2(65535.0);
|
||||
|
||||
for (int dy = -1; dy <= 1; dy++) {
|
||||
for (int dx = -1; dx <= 1; dx++) {
|
||||
vec2 offset = vec2(float(dx), float(dy)) * u_step_size;
|
||||
vec2 sampleUV = v_texCoord + offset * texelSize;
|
||||
|
||||
if (sampleUV.x < 0.0 || sampleUV.x > 1.0 || sampleUV.y < 0.0 || sampleUV.y > 1.0)
|
||||
continue;
|
||||
|
||||
vec4 encoded = texture2D(u_texture, sampleUV);
|
||||
if (isNoSeed(encoded))
|
||||
continue;
|
||||
|
||||
vec2 seed = decodeSeed(encoded);
|
||||
float dist = distance(pixelCoord, seed);
|
||||
if (dist < bestDist) {
|
||||
bestDist = dist;
|
||||
bestSeed = seed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestDist < 1e9) {
|
||||
gl_FragColor = encodeSeed(bestSeed);
|
||||
} else {
|
||||
gl_FragColor = vec4(1.0, 1.0, 1.0, 1.0);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Vendored
-4
@@ -1,4 +0,0 @@
|
||||
declare module "*.glsl" {
|
||||
const value: string;
|
||||
export default value;
|
||||
}
|
||||
Reference in New Issue
Block a user