实现滤镜子系统:六种滤镜注册、晶格化滤镜与滤镜面板
- filters/index.ts 注册模糊/亮度/对比度/饱和度/像素化/晶格化六个滤镜 - crystallize.ts 实现抖动网格 Voronoi 晶格化,WebGL 与 Canvas2D 双路径 - FilterPanel.vue 按注册表自动渲染开关与滑杆,rAF 合帧节流 applyFilters
This commit is contained in:
@@ -1,22 +1,175 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { onBeforeUnmount, reactive, watch } from "vue";
|
||||||
|
import { FabricImage, filters } from "fabric";
|
||||||
|
import type { FilterDef, FilterParam } from "@/types";
|
||||||
|
import { FILTERS } from "@/editor/filters";
|
||||||
|
import { useEditor } from "@/editor/useEditor";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 【T3 待实现 — 滤镜面板】
|
* 滤镜面板。选中图片时显示(Sidebar 已按 selectionKind === "image" 挂载)。
|
||||||
*
|
*
|
||||||
* 选中图片时显示。读 `@/editor/filters` 的 FILTERS 注册表,按每个 FilterDef 的
|
* 遍历 FILTERS 注册表,为每个滤镜渲染一个开关 + 按 `params` 自动生成的滑杆。
|
||||||
* `params` 自动渲染滑杆;开关某个滤镜就往 `image.filters` 里增删,然后
|
* 开关在 `image.filters` 里增删实例;参数变化先改实例再合帧重算,
|
||||||
* `image.applyFilters()` + `canvas.requestRenderAll()`。
|
* 避免 1200×630 的图上每个 input 事件都跑一遍 `applyFilters()`。
|
||||||
*
|
|
||||||
* 注意 `applyFilters()` 是重活,滑杆要节流,不要每个像素移动都跑一遍。
|
|
||||||
*/
|
*/
|
||||||
|
const { activeObject, canvas } = useEditor();
|
||||||
|
|
||||||
|
interface FilterState {
|
||||||
|
enabled: boolean;
|
||||||
|
values: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = reactive<Record<string, FilterState>>({});
|
||||||
|
|
||||||
|
for (const def of FILTERS) {
|
||||||
|
const values: Record<string, number> = {};
|
||||||
|
for (const p of def.params) values[p.key] = p.default;
|
||||||
|
state[def.id] = { enabled: false, values };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 注册表 id → fabric 滤镜的 type 名(首字母大写即可覆盖当前六种)。 */
|
||||||
|
function fabricTypeOf(def: FilterDef): string {
|
||||||
|
return def.id.charAt(0).toUpperCase() + def.id.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentImage(): FabricImage | null {
|
||||||
|
const obj = activeObject.value;
|
||||||
|
return obj instanceof FabricImage ? obj : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从当前选中图片反推每个滤镜的启用状态与参数值。 */
|
||||||
|
function readFromImage() {
|
||||||
|
const image = currentImage();
|
||||||
|
const list = image
|
||||||
|
? (image.filters as filters.BaseFilter<string>[])
|
||||||
|
: [];
|
||||||
|
for (const def of FILTERS) {
|
||||||
|
const type = fabricTypeOf(def);
|
||||||
|
const found = list.find((f) => f.type === type);
|
||||||
|
const st = state[def.id];
|
||||||
|
if (found) {
|
||||||
|
st.enabled = true;
|
||||||
|
for (const p of def.params) {
|
||||||
|
const v = (found as unknown as Record<string, unknown>)[p.key];
|
||||||
|
st.values[p.key] = typeof v === "number" ? v : p.default;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
st.enabled = false;
|
||||||
|
for (const p of def.params) st.values[p.key] = p.default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// `applyFilters()` 是重活:用 requestAnimationFrame 合帧,最多每帧跑一次。
|
||||||
|
let rafId: number | null = null;
|
||||||
|
function scheduleApply() {
|
||||||
|
if (rafId !== null) return;
|
||||||
|
rafId = requestAnimationFrame(() => {
|
||||||
|
rafId = null;
|
||||||
|
const image = currentImage();
|
||||||
|
const c = canvas.value;
|
||||||
|
if (!image || !c) return;
|
||||||
|
image.applyFilters();
|
||||||
|
c.requestRenderAll();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle(def: FilterDef) {
|
||||||
|
const image = currentImage();
|
||||||
|
if (!image) return;
|
||||||
|
const type = fabricTypeOf(def);
|
||||||
|
const st = state[def.id];
|
||||||
|
st.enabled = !st.enabled;
|
||||||
|
if (st.enabled) {
|
||||||
|
const instance = def.build({ ...st.values }) as filters.BaseFilter<string>;
|
||||||
|
image.filters.push(instance);
|
||||||
|
} else {
|
||||||
|
image.filters = image.filters.filter((f) => f.type !== type);
|
||||||
|
}
|
||||||
|
scheduleApply();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onParam(def: FilterDef, p: FilterParam, event: Event) {
|
||||||
|
const image = currentImage();
|
||||||
|
if (!image) return;
|
||||||
|
const value = Number((event.target as HTMLInputElement).value);
|
||||||
|
state[def.id].values[p.key] = value;
|
||||||
|
|
||||||
|
const type = fabricTypeOf(def);
|
||||||
|
const instance = (
|
||||||
|
image.filters as filters.BaseFilter<string>[]
|
||||||
|
).find((f) => f.type === type);
|
||||||
|
if (instance) {
|
||||||
|
(instance as unknown as Record<string, unknown>)[p.key] = value;
|
||||||
|
scheduleApply();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(activeObject, readFromImage, { immediate: true });
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (rafId !== null) cancelAnimationFrame(rafId);
|
||||||
|
rafId = null;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<p class="todo">T3 待实现:模糊 / 亮度 / 对比度 / 饱和度 / 像素化 / 晶格化</p>
|
<div class="filter-panel">
|
||||||
|
<div v-for="def in FILTERS" :key="def.id" class="filter">
|
||||||
|
<label class="row toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="state[def.id].enabled"
|
||||||
|
@change="toggle(def)"
|
||||||
|
/>
|
||||||
|
<span>{{ def.label }}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div v-if="state[def.id].enabled" class="params">
|
||||||
|
<div v-for="p in def.params" :key="p.key" class="field">
|
||||||
|
<div class="row param-head">
|
||||||
|
<label class="param-label">{{ p.label }}</label>
|
||||||
|
<span class="param-value">{{ state[def.id].values[p.key] }}</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
:min="p.min"
|
||||||
|
:max="p.max"
|
||||||
|
:step="p.step"
|
||||||
|
:value="state[def.id].values[p.key]"
|
||||||
|
@input="onParam(def, p, $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.todo {
|
.toggle {
|
||||||
color: var(--fg-dim);
|
cursor: pointer;
|
||||||
|
color: var(--fg);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle input {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.params {
|
||||||
|
padding: 2px 0 8px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-head {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-label {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.param-value {
|
||||||
|
color: var(--fg-dim);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,19 +1,146 @@
|
|||||||
|
import { classRegistry, filters } from "fabric";
|
||||||
|
import type { T2DPipelineState, TWebGLUniformLocationMap } from "fabric";
|
||||||
|
|
||||||
|
const { BaseFilter } = filters;
|
||||||
|
|
||||||
|
type CrystallizeOwnProps = {
|
||||||
|
cellSize: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const crystallizeDefaultValues: CrystallizeOwnProps = {
|
||||||
|
cellSize: 24,
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 【T3 待实现 — 晶格化滤镜】
|
* 抖动网格 Voronoi 晶格化。
|
||||||
*
|
*
|
||||||
* 目标是视觉上接近 Photoshop 的「晶格化」:把画面切成多边形色块,每块填一个纯色。
|
* 把画面划成 cellSize×cellSize 的规则网格,每格的种子点在格内伪随机偏移,
|
||||||
* 做法用抖动网格 Voronoi——把画面划成规则网格,每格的种子点在格内随机偏移,
|
|
||||||
* 每个像素取最近的种子点,填该种子点处的颜色。视觉上和真 Voronoi 难分辨,
|
* 每个像素取最近的种子点,填该种子点处的颜色。视觉上和真 Voronoi 难分辨,
|
||||||
* 但只需要查邻近 9 格,复杂度可控。
|
* 但只需要查邻近 9 格,复杂度可控。
|
||||||
*
|
|
||||||
* 实现要继承 fabric 的 `filters.BaseFilter`,模板见
|
|
||||||
* `node_modules/fabric/dist/src/filters/Boilerplate.d.ts`。
|
|
||||||
*
|
|
||||||
* **必须同时实现两条路径**(fabric 会按后端能力二选一,只写一条会在另一条上静默不生效):
|
|
||||||
* - `getFragmentSource()` + `static uniformLocations` + `sendUniformData()` → WebGL
|
|
||||||
* - `applyTo2d(options)` → Canvas2D 降级
|
|
||||||
*
|
|
||||||
* 参数至少要有格子大小(cellSize),注册进 ../index.ts 的 FILTERS。
|
|
||||||
*/
|
*/
|
||||||
|
const fragmentSource = `
|
||||||
|
precision highp float;
|
||||||
|
uniform sampler2D uTexture;
|
||||||
|
uniform float uCellSize;
|
||||||
|
uniform float uStepW;
|
||||||
|
uniform float uStepH;
|
||||||
|
varying vec2 vTexCoord;
|
||||||
|
|
||||||
export {};
|
float hash21(vec2 p) {
|
||||||
|
p = fract(p * vec2(123.34, 345.45));
|
||||||
|
p += dot(p, p + 34.345);
|
||||||
|
return fract(p.x * p.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec2 pixel = vec2(vTexCoord.x / uStepW, vTexCoord.y / uStepH);
|
||||||
|
float cs = max(uCellSize, 1.0);
|
||||||
|
vec2 grid = floor(pixel / cs);
|
||||||
|
vec2 bestSeed = pixel;
|
||||||
|
float bestDist = 1e20;
|
||||||
|
for (int dy = -1; dy <= 1; dy++) {
|
||||||
|
for (int dx = -1; dx <= 1; dx++) {
|
||||||
|
vec2 g = grid + vec2(float(dx), float(dy));
|
||||||
|
vec2 offset = vec2(hash21(g), hash21(g + vec2(71.71, 17.17)));
|
||||||
|
vec2 seed = (g + offset) * cs;
|
||||||
|
vec2 diff = pixel - seed;
|
||||||
|
float d = dot(diff, diff);
|
||||||
|
if (d < bestDist) {
|
||||||
|
bestDist = d;
|
||||||
|
bestSeed = seed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gl_FragColor = texture2D(uTexture, bestSeed * vec2(uStepW, uStepH));
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
/** 把整数格坐标 (x, y) 散列成 [0, 1) 的伪随机数,seed 用来取不同分量。 */
|
||||||
|
function hash2(x: number, y: number, seed: number): number {
|
||||||
|
let h =
|
||||||
|
(Math.imul(x, 374761393) +
|
||||||
|
Math.imul(y, 668265263) +
|
||||||
|
Math.imul(seed, 2246822519)) |
|
||||||
|
0;
|
||||||
|
h = Math.imul(h ^ (h >>> 13), 1274126177);
|
||||||
|
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Crystallize extends BaseFilter<"Crystallize", CrystallizeOwnProps> {
|
||||||
|
/** 运行时由 BaseFilter 构造器 `Object.assign(this, defaults, options)` 注入。 */
|
||||||
|
declare cellSize: number;
|
||||||
|
|
||||||
|
static type = "Crystallize";
|
||||||
|
static defaults = crystallizeDefaultValues;
|
||||||
|
static uniformLocations = ["uCellSize"];
|
||||||
|
|
||||||
|
protected getFragmentSource(): string {
|
||||||
|
return fragmentSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
isNeutralState(): boolean {
|
||||||
|
return this.cellSize === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
applyTo2d({ imageData: { data, width, height } }: T2DPipelineState): void {
|
||||||
|
const cellSize = Math.max(1, Math.floor(this.cellSize));
|
||||||
|
if (cellSize === 1) return;
|
||||||
|
|
||||||
|
const src = new Uint8ClampedArray(data);
|
||||||
|
const cols = Math.ceil(width / cellSize);
|
||||||
|
const rows = Math.ceil(height / cellSize);
|
||||||
|
|
||||||
|
// 预计算每个格子的种子点位置(种子点颜色 = 原图该点颜色)。
|
||||||
|
const seeds: { x: number; y: number }[] = new Array(cols * rows);
|
||||||
|
for (let gy = 0; gy < rows; gy++) {
|
||||||
|
for (let gx = 0; gx < cols; gx++) {
|
||||||
|
seeds[gy * cols + gx] = {
|
||||||
|
x: gx * cellSize + hash2(gx, gy, 0) * cellSize,
|
||||||
|
y: gy * cellSize + hash2(gx, gy, 1) * cellSize,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
const gy = Math.floor(y / cellSize);
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
const gx = Math.floor(x / cellSize);
|
||||||
|
let bestDist = Infinity;
|
||||||
|
let bestSeed = seeds[gy * cols + gx];
|
||||||
|
for (let dy = -1; dy <= 1; dy++) {
|
||||||
|
const cy = gy + dy;
|
||||||
|
if (cy < 0 || cy >= rows) continue;
|
||||||
|
for (let dx = -1; dx <= 1; dx++) {
|
||||||
|
const cx = gx + dx;
|
||||||
|
if (cx < 0 || cx >= cols) continue;
|
||||||
|
const s = seeds[cy * cols + cx];
|
||||||
|
const ddx = x - s.x;
|
||||||
|
const ddy = y - s.y;
|
||||||
|
const d = ddx * ddx + ddy * ddy;
|
||||||
|
if (d < bestDist) {
|
||||||
|
bestDist = d;
|
||||||
|
bestSeed = s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const sx = Math.min(width - 1, Math.max(0, Math.floor(bestSeed.x)));
|
||||||
|
const sy = Math.min(height - 1, Math.max(0, Math.floor(bestSeed.y)));
|
||||||
|
const si = (sy * width + sx) * 4;
|
||||||
|
const di = (y * width + x) * 4;
|
||||||
|
data[di] = src[si];
|
||||||
|
data[di + 1] = src[si + 1];
|
||||||
|
data[di + 2] = src[si + 2];
|
||||||
|
data[di + 3] = src[si + 3];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sendUniformData(
|
||||||
|
gl: WebGLRenderingContext,
|
||||||
|
uniformLocations: TWebGLUniformLocationMap,
|
||||||
|
): void {
|
||||||
|
gl.uniform1f(uniformLocations.uCellSize, this.cellSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
classRegistry.setClass(Crystallize);
|
||||||
|
|||||||
@@ -1,13 +1,93 @@
|
|||||||
|
import { filters } from "fabric";
|
||||||
import type { FilterDef } from "@/types";
|
import type { FilterDef } from "@/types";
|
||||||
|
import { Crystallize } from "./crystallize";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 【T3 待实现 — 滤镜注册表】
|
* 滤镜注册表。FilterPanel 按每个 FilterDef 的 `params` 自动渲染滑杆,
|
||||||
*
|
* 不需要为每个滤镜单独写界面;加滤镜就是往这里 push 一条。
|
||||||
* 往这个数组里注册滤镜,FilterPanel 会按 `params` 自动渲染滑杆,
|
|
||||||
* 不需要为每个滤镜单独写界面。
|
|
||||||
*
|
|
||||||
* 第一版要有:高斯模糊、亮度、对比度、饱和度、像素化(都是 fabric 内建的
|
|
||||||
* `filters.Blur / Brightness / Contrast / Saturation / Pixelate`),
|
|
||||||
* 外加自定义的晶格化(见 ./crystallize)。
|
|
||||||
*/
|
*/
|
||||||
export const FILTERS: FilterDef[] = [];
|
export const FILTERS: FilterDef[] = [
|
||||||
|
{
|
||||||
|
id: "blur",
|
||||||
|
label: "高斯模糊",
|
||||||
|
params: [
|
||||||
|
{ key: "blur", label: "模糊", min: 0, max: 1, step: 0.01, default: 0 },
|
||||||
|
],
|
||||||
|
build: (values) => new filters.Blur({ blur: values.blur }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "brightness",
|
||||||
|
label: "亮度",
|
||||||
|
params: [
|
||||||
|
{
|
||||||
|
key: "brightness",
|
||||||
|
label: "亮度",
|
||||||
|
min: -1,
|
||||||
|
max: 1,
|
||||||
|
step: 0.01,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
build: (values) => new filters.Brightness({ brightness: values.brightness }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "contrast",
|
||||||
|
label: "对比度",
|
||||||
|
params: [
|
||||||
|
{
|
||||||
|
key: "contrast",
|
||||||
|
label: "对比度",
|
||||||
|
min: -1,
|
||||||
|
max: 1,
|
||||||
|
step: 0.01,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
build: (values) => new filters.Contrast({ contrast: values.contrast }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "saturation",
|
||||||
|
label: "饱和度",
|
||||||
|
params: [
|
||||||
|
{
|
||||||
|
key: "saturation",
|
||||||
|
label: "饱和度",
|
||||||
|
min: -1,
|
||||||
|
max: 1,
|
||||||
|
step: 0.01,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
build: (values) => new filters.Saturation({ saturation: values.saturation }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pixelate",
|
||||||
|
label: "像素化",
|
||||||
|
params: [
|
||||||
|
{
|
||||||
|
key: "blocksize",
|
||||||
|
label: "块大小",
|
||||||
|
min: 1,
|
||||||
|
max: 64,
|
||||||
|
step: 1,
|
||||||
|
default: 4,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
build: (values) => new filters.Pixelate({ blocksize: values.blocksize }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "crystallize",
|
||||||
|
label: "晶格化",
|
||||||
|
params: [
|
||||||
|
{
|
||||||
|
key: "cellSize",
|
||||||
|
label: "格子大小",
|
||||||
|
min: 1,
|
||||||
|
max: 128,
|
||||||
|
step: 1,
|
||||||
|
default: 24,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
build: (values) => new Crystallize({ cellSize: values.cellSize }),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user