From 9f2b14572d9571ecace63eab4447c9539831f07c Mon Sep 17 00:00:00 2001 From: Jz0ojiang Date: Fri, 4 Sep 2026 16:03:06 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E6=BB=A4=E9=95=9C=E5=AD=90?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=EF=BC=9A=E5=85=AD=E7=A7=8D=E6=BB=A4=E9=95=9C?= =?UTF-8?q?=E6=B3=A8=E5=86=8C=E3=80=81=E6=99=B6=E6=A0=BC=E5=8C=96=E6=BB=A4?= =?UTF-8?q?=E9=95=9C=E4=B8=8E=E6=BB=A4=E9=95=9C=E9=9D=A2=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - filters/index.ts 注册模糊/亮度/对比度/饱和度/像素化/晶格化六个滤镜 - crystallize.ts 实现抖动网格 Voronoi 晶格化,WebGL 与 Canvas2D 双路径 - FilterPanel.vue 按注册表自动渲染开关与滑杆,rAF 合帧节流 applyFilters --- src/components/panels/FilterPanel.vue | 171 ++++++++++++++++++++++++-- src/editor/filters/crystallize.ts | 153 +++++++++++++++++++++-- src/editor/filters/index.ts | 98 +++++++++++++-- 3 files changed, 391 insertions(+), 31 deletions(-) diff --git a/src/components/panels/FilterPanel.vue b/src/components/panels/FilterPanel.vue index 1290725..72f6bed 100644 --- a/src/components/panels/FilterPanel.vue +++ b/src/components/panels/FilterPanel.vue @@ -1,22 +1,175 @@ diff --git a/src/editor/filters/crystallize.ts b/src/editor/filters/crystallize.ts index 90b4cba..2194927 100644 --- a/src/editor/filters/crystallize.ts +++ b/src/editor/filters/crystallize.ts @@ -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); diff --git a/src/editor/filters/index.ts b/src/editor/filters/index.ts index 8a40e97..0d8b28a 100644 --- a/src/editor/filters/index.ts +++ b/src/editor/filters/index.ts @@ -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 }), + }, +];