diff --git a/src/components/panels/FilterPanel.vue b/src/components/panels/FilterPanel.vue index 1290725..f361858 100644 --- a/src/components/panels/FilterPanel.vue +++ b/src/components/panels/FilterPanel.vue @@ -1,22 +1,170 @@ diff --git a/src/editor/filters/crystallize.ts b/src/editor/filters/crystallize.ts index 90b4cba..866bf44 100644 --- a/src/editor/filters/crystallize.ts +++ b/src/editor/filters/crystallize.ts @@ -1,19 +1,136 @@ +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); + + // 抖动网格 Voronoi。越界格也参与「取最近」,与 WebGL 路径一致; + // 采样坐标在下方 clamp 回图内,等价于纹理的 CLAMP_TO_EDGE。 + const seedAt = (gx: number, gy: number) => ({ + 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 = seedAt(gx, gy); + for (let dy = -1; dy <= 1; dy++) { + for (let dx = -1; dx <= 1; dx++) { + const s = seedAt(gx + dx, gy + dy); + 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..276c2e4 100644 --- a/src/editor/filters/index.ts +++ b/src/editor/filters/index.ts @@ -1,13 +1,107 @@ +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 }), + }, +]; + +/** + * 每个滤镜的 fabric 真实 `type`,从 `build()` 造出的实例上读取, + * 不靠 `id` 的首字母大写拼写猜测——id 与 fabric type 是两套独立命名。 + */ +function typeOf(def: FilterDef): string { + const values: Record = {}; + for (const p of def.params) values[p.key] = p.default; + return (def.build(values) as filters.BaseFilter).type; +} + +export const FILTER_TYPES: Readonly> = Object.fromEntries( + FILTERS.map((def) => [def.id, typeOf(def)]), +);