Implement professional-grade Gaussian-approximated blur effect with resolution independence and configurable quality for image effector system (#16571)
* Initial plan * Implement blur effect for image effector system Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com> * Improve blur quality with configurable sample count Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com> * Revert to simpler blur implementation with configurable sample count Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com> * Fix blur size independence from sample count Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com> * Make blur radius resolution-independent Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com> * Update blur.ts * Enhance blur quality with explicit diagonal sampling and fix parameter configuration Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com> * Implement Gaussian-approximated blur with distance-based weighting for superior quality Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com> * Revert "Implement Gaussian-approximated blur with distance-based weighting for superior quality" This reverts commitc739e9f55b
. * Revert "Enhance blur quality with explicit diagonal sampling and fix parameter configuration" This reverts commitffbc6eeba7
. * wip * tweak * ellipse --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
This commit is contained in:
parent
9d70c9ad78
commit
2f52c20150
|
@ -3194,6 +3194,7 @@ _imageEffector:
|
|||
mirror: "Mirror"
|
||||
invert: "Invert Colors"
|
||||
grayscale: "Grayscale"
|
||||
blur: "Blur"
|
||||
colorAdjust: "Color Correction"
|
||||
colorClamp: "Color Compression"
|
||||
colorClampAdvanced: "Color Compression (Advanced)"
|
||||
|
@ -3209,6 +3210,8 @@ _imageEffector:
|
|||
angle: "Angle"
|
||||
scale: "Size"
|
||||
size: "Size"
|
||||
radius: "Radius"
|
||||
samples: "Samples"
|
||||
color: "Color"
|
||||
opacity: "Opacity"
|
||||
normalize: "Normalize"
|
||||
|
|
|
@ -12346,6 +12346,10 @@ export interface Locale extends ILocale {
|
|||
* 白黒
|
||||
*/
|
||||
"grayscale": string;
|
||||
/**
|
||||
* ぼかし
|
||||
*/
|
||||
"blur": string;
|
||||
/**
|
||||
* 色調補正
|
||||
*/
|
||||
|
@ -12391,9 +12395,9 @@ export interface Locale extends ILocale {
|
|||
*/
|
||||
"tearing": string;
|
||||
/**
|
||||
* 塗りつぶし(四角)
|
||||
* 塗りつぶし
|
||||
*/
|
||||
"fillSquare": string;
|
||||
"fill": string;
|
||||
};
|
||||
"_fxProps": {
|
||||
/**
|
||||
|
@ -12408,6 +12412,14 @@ export interface Locale extends ILocale {
|
|||
* サイズ
|
||||
*/
|
||||
"size": string;
|
||||
/**
|
||||
* 半径
|
||||
*/
|
||||
"radius": string;
|
||||
/**
|
||||
* サンプル数
|
||||
*/
|
||||
"samples": string;
|
||||
/**
|
||||
* 位置
|
||||
*/
|
||||
|
@ -12524,6 +12536,10 @@ export interface Locale extends ILocale {
|
|||
* 黒色にする
|
||||
*/
|
||||
"zoomLinesBlack": string;
|
||||
/**
|
||||
* 円形
|
||||
*/
|
||||
"circle": string;
|
||||
};
|
||||
};
|
||||
/**
|
||||
|
|
|
@ -3306,6 +3306,7 @@ _imageEffector:
|
|||
mirror: "ミラー"
|
||||
invert: "色の反転"
|
||||
grayscale: "白黒"
|
||||
blur: "ぼかし"
|
||||
colorAdjust: "色調補正"
|
||||
colorClamp: "色の圧縮"
|
||||
colorClampAdvanced: "色の圧縮(高度)"
|
||||
|
@ -3317,12 +3318,14 @@ _imageEffector:
|
|||
checker: "チェッカー"
|
||||
blockNoise: "ブロックノイズ"
|
||||
tearing: "ティアリング"
|
||||
fillSquare: "塗りつぶし(四角)"
|
||||
fill: "塗りつぶし"
|
||||
|
||||
_fxProps:
|
||||
angle: "角度"
|
||||
scale: "サイズ"
|
||||
size: "サイズ"
|
||||
radius: "半径"
|
||||
samples: "サンプル数"
|
||||
offset: "位置"
|
||||
color: "色"
|
||||
opacity: "不透明度"
|
||||
|
@ -3352,6 +3355,7 @@ _imageEffector:
|
|||
zoomLinesThreshold: "集中線の幅"
|
||||
zoomLinesMaskSize: "中心径"
|
||||
zoomLinesBlack: "黒色にする"
|
||||
circle: "円形"
|
||||
|
||||
drafts: "下書き"
|
||||
_drafts:
|
||||
|
|
|
@ -23,7 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<div :class="$style.previewContainer">
|
||||
<div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div>
|
||||
<div class="_acrylic" :class="$style.editControls">
|
||||
<button class="_button" :class="[$style.previewControlsButton, fillSquare ? $style.active : null]" @click="fillSquare = true"><i class="ti ti-pencil"></i></button>
|
||||
<button class="_button" :class="[$style.previewControlsButton, penMode != null ? $style.active : null]" @click="showPenMenu"><i class="ti ti-pencil"></i></button>
|
||||
</div>
|
||||
<div class="_acrylic" :class="$style.previewControls">
|
||||
<button class="_button" :class="[$style.previewControlsButton, !enabled ? $style.active : null]" @click="enabled = false">Before</button>
|
||||
|
@ -216,10 +216,24 @@ watch(enabled, () => {
|
|||
}
|
||||
});
|
||||
|
||||
const fillSquare = ref(false);
|
||||
const penMode = ref<'fill' | 'blur' | null>(null);
|
||||
|
||||
function showPenMenu(ev: MouseEvent) {
|
||||
os.popupMenu([{
|
||||
text: i18n.ts._imageEffector._fxs.fill,
|
||||
action: () => {
|
||||
penMode.value = 'fill';
|
||||
},
|
||||
}, {
|
||||
text: i18n.ts._imageEffector._fxs.blur,
|
||||
action: () => {
|
||||
penMode.value = 'blur';
|
||||
},
|
||||
}], ev.currentTarget ?? ev.target);
|
||||
}
|
||||
|
||||
function onImagePointerdown(ev: PointerEvent) {
|
||||
if (canvasEl.value == null || imageBitmap == null || !fillSquare.value) return;
|
||||
if (canvasEl.value == null || imageBitmap == null || penMode.value == null) return;
|
||||
|
||||
const AW = canvasEl.value.clientWidth;
|
||||
const AH = canvasEl.value.clientHeight;
|
||||
|
@ -250,19 +264,34 @@ function onImagePointerdown(ev: PointerEvent) {
|
|||
}
|
||||
|
||||
const id = genId();
|
||||
layers.push({
|
||||
id,
|
||||
fxId: 'fillSquare',
|
||||
params: {
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
scaleX: 0.1,
|
||||
scaleY: 0.1,
|
||||
angle: 0,
|
||||
opacity: 1,
|
||||
color: [1, 1, 1],
|
||||
},
|
||||
});
|
||||
if (penMode.value === 'fill') {
|
||||
layers.push({
|
||||
id,
|
||||
fxId: 'fill',
|
||||
params: {
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
scaleX: 0.1,
|
||||
scaleY: 0.1,
|
||||
angle: 0,
|
||||
opacity: 1,
|
||||
color: [1, 1, 1],
|
||||
},
|
||||
});
|
||||
} else if (penMode.value === 'blur') {
|
||||
layers.push({
|
||||
id,
|
||||
fxId: 'blur',
|
||||
params: {
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
scaleX: 0.1,
|
||||
scaleY: 0.1,
|
||||
angle: 0,
|
||||
radius: 3,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
_move(ev.offsetX, ev.offsetY);
|
||||
|
||||
|
@ -302,7 +331,7 @@ function onImagePointerdown(ev: PointerEvent) {
|
|||
canvasEl.value?.removeEventListener('pointercancel', up);
|
||||
canvasEl.value?.releasePointerCapture(ev.pointerId);
|
||||
|
||||
fillSquare.value = false;
|
||||
penMode.value = null;
|
||||
}
|
||||
|
||||
canvasEl.value.addEventListener('pointermove', move);
|
||||
|
|
|
@ -18,7 +18,8 @@ import { FX_stripe } from './fxs/stripe.js';
|
|||
import { FX_threshold } from './fxs/threshold.js';
|
||||
import { FX_zoomLines } from './fxs/zoomLines.js';
|
||||
import { FX_blockNoise } from './fxs/blockNoise.js';
|
||||
import { FX_fillSquare } from './fxs/fillSquare.js';
|
||||
import { FX_fill } from './fxs/fill.js';
|
||||
import { FX_blur } from './fxs/blur.js';
|
||||
import type { ImageEffectorFx } from './ImageEffector.js';
|
||||
|
||||
export const FXS = [
|
||||
|
@ -37,5 +38,6 @@ export const FXS = [
|
|||
FX_chromaticAberration,
|
||||
FX_tearing,
|
||||
FX_blockNoise,
|
||||
FX_fillSquare,
|
||||
FX_fill,
|
||||
FX_blur,
|
||||
] as const satisfies ImageEffectorFx<string, any>[];
|
||||
|
|
|
@ -0,0 +1,157 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { defineImageEffectorFx } from '../ImageEffector.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
const shader = `#version 300 es
|
||||
precision mediump float;
|
||||
|
||||
const float PI = 3.141592653589793;
|
||||
const float TWO_PI = 6.283185307179586;
|
||||
const float HALF_PI = 1.5707963267948966;
|
||||
|
||||
in vec2 in_uv;
|
||||
uniform sampler2D in_texture;
|
||||
uniform vec2 in_resolution;
|
||||
uniform vec2 u_offset;
|
||||
uniform vec2 u_scale;
|
||||
uniform bool u_ellipse;
|
||||
uniform float u_angle;
|
||||
uniform float u_radius;
|
||||
uniform int u_samples;
|
||||
out vec4 out_color;
|
||||
|
||||
void main() {
|
||||
float angle = -(u_angle * PI);
|
||||
vec2 centeredUv = in_uv - vec2(0.5, 0.5) - u_offset;
|
||||
vec2 rotatedUV = vec2(
|
||||
centeredUv.x * cos(angle) - centeredUv.y * sin(angle),
|
||||
centeredUv.x * sin(angle) + centeredUv.y * cos(angle)
|
||||
) + u_offset;
|
||||
|
||||
bool isInside = false;
|
||||
if (u_ellipse) {
|
||||
vec2 norm = (rotatedUV - u_offset) / u_scale;
|
||||
isInside = dot(norm, norm) <= 1.0;
|
||||
} else {
|
||||
isInside = rotatedUV.x > u_offset.x - u_scale.x && rotatedUV.x < u_offset.x + u_scale.x && rotatedUV.y > u_offset.y - u_scale.y && rotatedUV.y < u_offset.y + u_scale.y;
|
||||
}
|
||||
|
||||
if (!isInside) {
|
||||
out_color = texture(in_texture, in_uv);
|
||||
return;
|
||||
}
|
||||
|
||||
vec4 result = vec4(0.0);
|
||||
float totalSamples = 0.0;
|
||||
|
||||
// Make blur radius resolution-independent by using a percentage of image size
|
||||
// This ensures consistent visual blur regardless of image resolution
|
||||
float referenceSize = min(in_resolution.x, in_resolution.y);
|
||||
float normalizedRadius = u_radius / 100.0; // Convert radius to percentage (0-15 -> 0-0.15)
|
||||
vec2 blurOffset = vec2(normalizedRadius) / in_resolution * referenceSize;
|
||||
|
||||
// Calculate how many samples to take in each direction
|
||||
// This determines the grid density, not the blur extent
|
||||
int sampleRadius = int(sqrt(float(u_samples)) / 2.0);
|
||||
|
||||
// Sample in a grid pattern within the specified radius
|
||||
for (int x = -sampleRadius; x <= sampleRadius; x++) {
|
||||
for (int y = -sampleRadius; y <= sampleRadius; y++) {
|
||||
// Normalize the grid position to [-1, 1] range
|
||||
float normalizedX = float(x) / float(sampleRadius);
|
||||
float normalizedY = float(y) / float(sampleRadius);
|
||||
|
||||
// Scale by radius to get the actual sampling offset
|
||||
vec2 offset = vec2(normalizedX, normalizedY) * blurOffset;
|
||||
vec2 sampleUV = in_uv + offset;
|
||||
|
||||
// Only sample if within texture bounds
|
||||
if (sampleUV.x >= 0.0 && sampleUV.x <= 1.0 && sampleUV.y >= 0.0 && sampleUV.y <= 1.0) {
|
||||
result += texture(in_texture, sampleUV);
|
||||
totalSamples += 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out_color = totalSamples > 0.0 ? result / totalSamples : texture(in_texture, in_uv);
|
||||
}
|
||||
`;
|
||||
|
||||
export const FX_blur = defineImageEffectorFx({
|
||||
id: 'blur',
|
||||
name: i18n.ts._imageEffector._fxs.blur,
|
||||
shader,
|
||||
uniforms: ['offset', 'scale', 'ellipse', 'angle', 'radius', 'samples'] as const,
|
||||
params: {
|
||||
offsetX: {
|
||||
label: i18n.ts._imageEffector._fxProps.offset + ' X',
|
||||
type: 'number',
|
||||
default: 0.0,
|
||||
min: -1.0,
|
||||
max: 1.0,
|
||||
step: 0.01,
|
||||
toViewValue: v => Math.round(v * 100) + '%',
|
||||
},
|
||||
offsetY: {
|
||||
label: i18n.ts._imageEffector._fxProps.offset + ' Y',
|
||||
type: 'number',
|
||||
default: 0.0,
|
||||
min: -1.0,
|
||||
max: 1.0,
|
||||
step: 0.01,
|
||||
toViewValue: v => Math.round(v * 100) + '%',
|
||||
},
|
||||
scaleX: {
|
||||
label: i18n.ts._imageEffector._fxProps.scale + ' W',
|
||||
type: 'number',
|
||||
default: 0.5,
|
||||
min: 0.0,
|
||||
max: 1.0,
|
||||
step: 0.01,
|
||||
toViewValue: v => Math.round(v * 100) + '%',
|
||||
},
|
||||
scaleY: {
|
||||
label: i18n.ts._imageEffector._fxProps.scale + ' H',
|
||||
type: 'number',
|
||||
default: 0.5,
|
||||
min: 0.0,
|
||||
max: 1.0,
|
||||
step: 0.01,
|
||||
toViewValue: v => Math.round(v * 100) + '%',
|
||||
},
|
||||
ellipse: {
|
||||
label: i18n.ts._imageEffector._fxProps.circle,
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
},
|
||||
angle: {
|
||||
label: i18n.ts._imageEffector._fxProps.angle,
|
||||
type: 'number',
|
||||
default: 0,
|
||||
min: -1.0,
|
||||
max: 1.0,
|
||||
step: 0.01,
|
||||
toViewValue: v => Math.round(v * 90) + '°',
|
||||
},
|
||||
radius: {
|
||||
label: i18n.ts._imageEffector._fxProps.strength,
|
||||
type: 'number',
|
||||
default: 3.0,
|
||||
min: 0.0,
|
||||
max: 10.0,
|
||||
step: 0.5,
|
||||
},
|
||||
},
|
||||
main: ({ gl, u, params }) => {
|
||||
gl.uniform2f(u.offset, params.offsetX / 2, params.offsetY / 2);
|
||||
gl.uniform2f(u.scale, params.scaleX / 2, params.scaleY / 2);
|
||||
gl.uniform1i(u.ellipse, params.ellipse ? 1 : 0);
|
||||
gl.uniform1f(u.angle, params.angle / 2);
|
||||
gl.uniform1f(u.radius, params.radius);
|
||||
gl.uniform1i(u.samples, 256);
|
||||
},
|
||||
});
|
|
@ -18,6 +18,7 @@ uniform sampler2D in_texture;
|
|||
uniform vec2 in_resolution;
|
||||
uniform vec2 u_offset;
|
||||
uniform vec2 u_scale;
|
||||
uniform bool u_ellipse;
|
||||
uniform float u_angle;
|
||||
uniform vec3 u_color;
|
||||
uniform float u_opacity;
|
||||
|
@ -35,7 +36,13 @@ void main() {
|
|||
centeredUv.x * sin(angle) + centeredUv.y * cos(angle)
|
||||
) + u_offset;
|
||||
|
||||
bool isInside = rotatedUV.x > u_offset.x - u_scale.x && rotatedUV.x < u_offset.x + u_scale.x && rotatedUV.y > u_offset.y - u_scale.y && rotatedUV.y < u_offset.y + u_scale.y;
|
||||
bool isInside = false;
|
||||
if (u_ellipse) {
|
||||
vec2 norm = (rotatedUV - u_offset) / u_scale;
|
||||
isInside = dot(norm, norm) <= 1.0;
|
||||
} else {
|
||||
isInside = rotatedUV.x > u_offset.x - u_scale.x && rotatedUV.x < u_offset.x + u_scale.x && rotatedUV.y > u_offset.y - u_scale.y && rotatedUV.y < u_offset.y + u_scale.y;
|
||||
}
|
||||
|
||||
out_color = isInside ? vec4(
|
||||
mix(in_color.r, u_color.r, u_opacity),
|
||||
|
@ -46,11 +53,11 @@ void main() {
|
|||
}
|
||||
`;
|
||||
|
||||
export const FX_fillSquare = defineImageEffectorFx({
|
||||
id: 'fillSquare',
|
||||
name: i18n.ts._imageEffector._fxs.fillSquare,
|
||||
export const FX_fill = defineImageEffectorFx({
|
||||
id: 'fill',
|
||||
name: i18n.ts._imageEffector._fxs.fill,
|
||||
shader,
|
||||
uniforms: ['offset', 'scale', 'angle', 'color', 'opacity'] as const,
|
||||
uniforms: ['offset', 'scale', 'ellipse', 'angle', 'color', 'opacity'] as const,
|
||||
params: {
|
||||
offsetX: {
|
||||
label: i18n.ts._imageEffector._fxProps.offset + ' X',
|
||||
|
@ -71,7 +78,7 @@ export const FX_fillSquare = defineImageEffectorFx({
|
|||
toViewValue: v => Math.round(v * 100) + '%',
|
||||
},
|
||||
scaleX: {
|
||||
label: i18n.ts._imageEffector._fxProps.scale + ' X',
|
||||
label: i18n.ts._imageEffector._fxProps.scale + ' W',
|
||||
type: 'number',
|
||||
default: 0.5,
|
||||
min: 0.0,
|
||||
|
@ -80,7 +87,7 @@ export const FX_fillSquare = defineImageEffectorFx({
|
|||
toViewValue: v => Math.round(v * 100) + '%',
|
||||
},
|
||||
scaleY: {
|
||||
label: i18n.ts._imageEffector._fxProps.scale + ' Y',
|
||||
label: i18n.ts._imageEffector._fxProps.scale + ' H',
|
||||
type: 'number',
|
||||
default: 0.5,
|
||||
min: 0.0,
|
||||
|
@ -88,6 +95,11 @@ export const FX_fillSquare = defineImageEffectorFx({
|
|||
step: 0.01,
|
||||
toViewValue: v => Math.round(v * 100) + '%',
|
||||
},
|
||||
ellipse: {
|
||||
label: i18n.ts._imageEffector._fxProps.circle,
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
},
|
||||
angle: {
|
||||
label: i18n.ts._imageEffector._fxProps.angle,
|
||||
type: 'number',
|
||||
|
@ -115,6 +127,7 @@ export const FX_fillSquare = defineImageEffectorFx({
|
|||
main: ({ gl, u, params }) => {
|
||||
gl.uniform2f(u.offset, params.offsetX / 2, params.offsetY / 2);
|
||||
gl.uniform2f(u.scale, params.scaleX / 2, params.scaleY / 2);
|
||||
gl.uniform1i(u.ellipse, params.ellipse ? 1 : 0);
|
||||
gl.uniform1f(u.angle, params.angle / 2);
|
||||
gl.uniform3f(u.color, params.color[0], params.color[1], params.color[2]);
|
||||
gl.uniform1f(u.opacity, params.opacity);
|
Loading…
Reference in New Issue