实现滤镜子系统:六种滤镜注册、晶格化滤镜与滤镜面板
- 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">
|
||||
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 的
|
||||
* `params` 自动渲染滑杆;开关某个滤镜就往 `image.filters` 里增删,然后
|
||||
* `image.applyFilters()` + `canvas.requestRenderAll()`。
|
||||
*
|
||||
* 注意 `applyFilters()` 是重活,滑杆要节流,不要每个像素移动都跑一遍。
|
||||
* 遍历 FILTERS 注册表,为每个滤镜渲染一个开关 + 按 `params` 自动生成的滑杆。
|
||||
* 开关在 `image.filters` 里增删实例;参数变化先改实例再合帧重算,
|
||||
* 避免 1200×630 的图上每个 input 事件都跑一遍 `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>
|
||||
|
||||
<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>
|
||||
|
||||
<style scoped>
|
||||
.todo {
|
||||
color: var(--fg-dim);
|
||||
.toggle {
|
||||
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;
|
||||
}
|
||||
|
||||
.param-value {
|
||||
color: var(--fg-dim);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</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 的「晶格化」:把画面切成多边形色块,每块填一个纯色。
|
||||
* 做法用抖动网格 Voronoi——把画面划成规则网格,每格的种子点在格内随机偏移,
|
||||
* 把画面划成 cellSize×cellSize 的规则网格,每格的种子点在格内伪随机偏移,
|
||||
* 每个像素取最近的种子点,填该种子点处的颜色。视觉上和真 Voronoi 难分辨,
|
||||
* 但只需要查邻近 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 { Crystallize } from "./crystallize";
|
||||
|
||||
/**
|
||||
* 【T3 待实现 — 滤镜注册表】
|
||||
*
|
||||
* 往这个数组里注册滤镜,FilterPanel 会按 `params` 自动渲染滑杆,
|
||||
* 不需要为每个滤镜单独写界面。
|
||||
*
|
||||
* 第一版要有:高斯模糊、亮度、对比度、饱和度、像素化(都是 fabric 内建的
|
||||
* `filters.Blur / Brightness / Contrast / Saturation / Pixelate`),
|
||||
* 外加自定义的晶格化(见 ./crystallize)。
|
||||
* 滤镜注册表。FilterPanel 按每个 FilterDef 的 `params` 自动渲染滑杆,
|
||||
* 不需要为每个滤镜单独写界面;加滤镜就是往这里 push 一条。
|
||||
*/
|
||||
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