Merge pull request #6 from luisacevelad/feat/generic-effects-pipeline

Feat/generic effects pipeline
This commit is contained in:
luisacevelad 2026-04-26 15:55:39 -05:00 committed by GitHub
commit 4c94c4d7da
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 638 additions and 77 deletions

View File

@ -0,0 +1,46 @@
import type { EffectDefinition } from "@/lib/effects/types";
export const BRIGHTNESS_CONTRAST_SHADER = "brightness-contrast";
export const brightnessContrastEffectDefinition: EffectDefinition = {
type: "brightness-contrast",
name: "Brightness / Contrast",
keywords: ["brightness", "contrast", "light", "dark", "exposure"],
params: [
{
key: "brightness",
label: "Brightness",
type: "number",
default: 0,
min: -1,
max: 1,
step: 0.01,
},
{
key: "contrast",
label: "Contrast",
type: "number",
default: 1,
min: 0,
max: 3,
step: 0.01,
},
],
renderer: {
passes: [
{
shader: BRIGHTNESS_CONTRAST_SHADER,
uniforms: ({ effectParams }) => ({
u_brightness:
typeof effectParams.brightness === "number"
? effectParams.brightness
: 0,
u_contrast:
typeof effectParams.contrast === "number"
? effectParams.contrast
: 1,
}),
},
],
},
};

View File

@ -0,0 +1,33 @@
import type { EffectDefinition } from "@/lib/effects/types";
export const GRAYSCALE_SHADER = "grayscale";
export const grayscaleEffectDefinition: EffectDefinition = {
type: "grayscale",
name: "Grayscale",
keywords: ["grayscale", "grey", "black and white", "monochrome", "desaturate"],
params: [
{
key: "intensity",
label: "Intensity",
type: "number",
default: 100,
min: 0,
max: 100,
step: 1,
},
],
renderer: {
passes: [
{
shader: GRAYSCALE_SHADER,
uniforms: ({ effectParams }) => {
const raw = effectParams.intensity;
const intensity =
typeof raw === "number" ? raw / 100 : 1;
return { u_intensity: intensity };
},
},
],
},
};

View File

@ -1,7 +1,21 @@
import { effectsRegistry } from "../registry";
import { blurEffectDefinition } from "./blur";
import { brightnessContrastEffectDefinition } from "./brightness-contrast";
import { grayscaleEffectDefinition } from "./grayscale";
import { invertEffectDefinition } from "./invert";
import { saturationEffectDefinition } from "./saturation";
import { sepiaEffectDefinition } from "./sepia";
import { vignetteEffectDefinition } from "./vignette";
const defaultEffects = [blurEffectDefinition];
const defaultEffects = [
blurEffectDefinition,
brightnessContrastEffectDefinition,
grayscaleEffectDefinition,
saturationEffectDefinition,
sepiaEffectDefinition,
invertEffectDefinition,
vignetteEffectDefinition,
];
export function registerDefaultEffects(): void {
for (const definition of defaultEffects) {

View File

@ -0,0 +1,33 @@
import type { EffectDefinition } from "@/lib/effects/types";
export const INVERT_SHADER = "invert";
export const invertEffectDefinition: EffectDefinition = {
type: "invert",
name: "Invert",
keywords: ["invert", "negative", "reverse", "opposite"],
params: [
{
key: "intensity",
label: "Intensity",
type: "number",
default: 100,
min: 0,
max: 100,
step: 1,
},
],
renderer: {
passes: [
{
shader: INVERT_SHADER,
uniforms: ({ effectParams }) => {
const raw = effectParams.intensity;
const intensity =
typeof raw === "number" ? raw / 100 : 1;
return { u_intensity: intensity };
},
},
],
},
};

View File

@ -0,0 +1,33 @@
import type { EffectDefinition } from "@/lib/effects/types";
export const SATURATION_SHADER = "saturation";
export const saturationEffectDefinition: EffectDefinition = {
type: "saturation",
name: "Saturation",
keywords: ["saturation", "vibrance", "vivid", "color", "desaturate"],
params: [
{
key: "saturation",
label: "Saturation",
type: "number",
default: 100,
min: 0,
max: 300,
step: 1,
},
],
renderer: {
passes: [
{
shader: SATURATION_SHADER,
uniforms: ({ effectParams }) => {
const raw = effectParams.saturation;
const saturation =
typeof raw === "number" ? raw / 100 : 1;
return { u_saturation: saturation };
},
},
],
},
};

View File

@ -0,0 +1,33 @@
import type { EffectDefinition } from "@/lib/effects/types";
export const SEPIA_SHADER = "sepia";
export const sepiaEffectDefinition: EffectDefinition = {
type: "sepia",
name: "Sepia",
keywords: ["sepia", "vintage", "warm", "old", "retro", "antique"],
params: [
{
key: "intensity",
label: "Intensity",
type: "number",
default: 100,
min: 0,
max: 100,
step: 1,
},
],
renderer: {
passes: [
{
shader: SEPIA_SHADER,
uniforms: ({ effectParams }) => {
const raw = effectParams.intensity;
const intensity =
typeof raw === "number" ? raw / 100 : 1;
return { u_intensity: intensity };
},
},
],
},
};

View File

@ -0,0 +1,48 @@
import type { EffectDefinition } from "@/lib/effects/types";
export const VIGNETTE_SHADER = "vignette";
export const vignetteEffectDefinition: EffectDefinition = {
type: "vignette",
name: "Vignette",
keywords: ["vignette", "dark edges", "focus", "spotlight", "center"],
params: [
{
key: "radius",
label: "Radius",
type: "number",
default: 75,
min: 10,
max: 100,
step: 1,
},
{
key: "softness",
label: "Softness",
type: "number",
default: 35,
min: 5,
max: 80,
step: 1,
},
],
renderer: {
passes: [
{
shader: VIGNETTE_SHADER,
uniforms: ({ effectParams }) => {
const rawRadius = effectParams.radius;
const rawSoftness = effectParams.softness;
const radius =
(typeof rawRadius === "number" ? rawRadius : 75) /
100;
const softness =
(typeof rawSoftness === "number"
? rawSoftness
: 35) / 100;
return { u_radius: radius, u_softness: softness };
},
},
],
},
};

View File

@ -7,8 +7,45 @@ use wgpu::util::DeviceExt;
use crate::{EffectPass, UniformValue};
const GAUSSIAN_BLUR_SHADER_ID: &str = "gaussian-blur";
const GAUSSIAN_BLUR_SHADER_SOURCE: &str = include_str!("shaders/gaussian_blur.wgsl");
type UniformPacker = fn(&EffectPass, u32, u32) -> Result<GenericUniformBuffer, EffectsError>;
const SHADER_REGISTRY: &[(&str, &str, UniformPacker)] = &[
(
"gaussian-blur",
include_str!("shaders/gaussian_blur.wgsl"),
pack_gaussian_blur as UniformPacker,
),
(
"brightness-contrast",
include_str!("shaders/brightness_contrast.wgsl"),
pack_brightness_contrast as UniformPacker,
),
(
"grayscale",
include_str!("shaders/grayscale.wgsl"),
pack_grayscale as UniformPacker,
),
(
"saturation",
include_str!("shaders/saturation.wgsl"),
pack_saturation as UniformPacker,
),
(
"sepia",
include_str!("shaders/sepia.wgsl"),
pack_sepia as UniformPacker,
),
(
"invert",
include_str!("shaders/invert.wgsl"),
pack_invert as UniformPacker,
),
(
"vignette",
include_str!("shaders/vignette.wgsl"),
pack_vignette as UniformPacker,
),
];
pub struct ApplyEffectsOptions<'a> {
pub source: &'a wgpu::Texture,
@ -40,16 +77,26 @@ pub enum EffectsError {
uniform: String,
expected_length: usize,
},
#[error("Shader '{shader}' does not support uniform '{uniform}'")]
UnsupportedUniform { shader: String, uniform: String },
}
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable)]
struct EffectUniformBuffer {
struct GenericUniformBuffer {
resolution: [f32; 2],
direction: [f32; 2],
scalars: [f32; 4],
_pad_res: [f32; 2],
scalars: [[f32; 4]; 2],
vectors: [[f32; 4]; 2],
}
impl Default for GenericUniformBuffer {
fn default() -> Self {
Self {
resolution: [0.0, 0.0],
_pad_res: [0.0, 0.0],
scalars: [[0.0; 4]; 2],
vectors: [[0.0; 4]; 2],
}
}
}
impl EffectPipeline {
@ -70,6 +117,7 @@ impl EffectPipeline {
count: None,
}],
});
let vertex_shader_module =
context
.device()
@ -77,13 +125,7 @@ impl EffectPipeline {
label: Some("effects-fullscreen-shader"),
source: wgpu::ShaderSource::Wgsl(FULLSCREEN_SHADER_SOURCE.into()),
});
let gaussian_blur_shader_module =
context
.device()
.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("effects-gaussian-blur-shader"),
source: wgpu::ShaderSource::Wgsl(GAUSSIAN_BLUR_SHADER_SOURCE.into()),
});
let pipeline_layout =
context
.device()
@ -95,44 +137,54 @@ impl EffectPipeline {
],
immediate_size: 0,
});
let gaussian_blur_pipeline =
context
.device()
.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("effects-gaussian-blur-pipeline"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &vertex_shader_module,
entry_point: Some("vertex_main"),
buffers: &[wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<[f32; 2]>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &[wgpu::VertexAttribute {
format: wgpu::VertexFormat::Float32x2,
offset: 0,
shader_location: 0,
let mut pipelines = HashMap::new();
for (id, source, _packer) in SHADER_REGISTRY {
let shader_module =
context
.device()
.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some(&format!("effects-{id}-shader")),
source: wgpu::ShaderSource::Wgsl((*source).into()),
});
let pipeline =
context
.device()
.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(&format!("effects-{id}-pipeline")),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &vertex_shader_module,
entry_point: Some("vertex_main"),
buffers: &[wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<[f32; 2]>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &[wgpu::VertexAttribute {
format: wgpu::VertexFormat::Float32x2,
offset: 0,
shader_location: 0,
}],
}],
}],
compilation_options: wgpu::PipelineCompilationOptions::default(),
},
fragment: Some(wgpu::FragmentState {
module: &gaussian_blur_shader_module,
entry_point: Some("fragment_main"),
targets: &[Some(wgpu::ColorTargetState {
format: context.texture_format(),
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: wgpu::PipelineCompilationOptions::default(),
}),
primitive: wgpu::PrimitiveState::default(),
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview_mask: None,
cache: None,
});
let pipelines =
HashMap::from([(GAUSSIAN_BLUR_SHADER_ID.to_string(), gaussian_blur_pipeline)]);
compilation_options: wgpu::PipelineCompilationOptions::default(),
},
fragment: Some(wgpu::FragmentState {
module: &shader_module,
entry_point: Some("fragment_main"),
targets: &[Some(wgpu::ColorTargetState {
format: context.texture_format(),
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: wgpu::PipelineCompilationOptions::default(),
}),
primitive: wgpu::PrimitiveState::default(),
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview_mask: None,
cache: None,
});
pipelines.insert(id.to_string(), pipeline);
}
Self {
uniform_bind_group_layout,
@ -206,12 +258,14 @@ impl EffectPipeline {
},
],
});
let packed = pack_uniforms_for_shader(pass, width, height)?;
let uniform_buffer =
context
.device()
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("effects-uniform-buffer"),
contents: bytemuck::bytes_of(&pack_effect_uniforms(pass, width, height)?),
contents: bytemuck::bytes_of(&packed),
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
});
let uniform_bind_group =
@ -262,30 +316,18 @@ impl EffectPipeline {
}
}
fn pack_effect_uniforms(
fn pack_uniforms_for_shader(
pass: &EffectPass,
width: u32,
height: u32,
) -> Result<EffectUniformBuffer, EffectsError> {
let shader = pass.shader.as_str();
let sigma = read_number_uniform(pass, "u_sigma")?;
let step = read_number_uniform(pass, "u_step")?;
let direction = read_vec2_uniform(pass, "u_direction")?;
for uniform in pass.uniforms.keys() {
if uniform == "u_sigma" || uniform == "u_step" || uniform == "u_direction" {
continue;
) -> Result<GenericUniformBuffer, EffectsError> {
for (id, _source, packer) in SHADER_REGISTRY {
if pass.shader == *id {
return packer(pass, width, height);
}
return Err(EffectsError::UnsupportedUniform {
shader: shader.to_string(),
uniform: uniform.clone(),
});
}
Ok(EffectUniformBuffer {
resolution: [width as f32, height as f32],
direction,
scalars: [sigma, step, 0.0, 0.0],
Err(EffectsError::UnknownEffectShader {
shader: pass.shader.clone(),
})
}
@ -305,6 +347,16 @@ fn read_number_uniform(pass: &EffectPass, uniform: &str) -> Result<f32, EffectsE
}
}
fn read_number_uniform_or(pass: &EffectPass, uniform: &str, default: f32) -> f32 {
pass.uniforms
.get(uniform)
.and_then(|v| match v {
UniformValue::Number(n) => Some(*n),
_ => None,
})
.unwrap_or(default)
}
fn read_vec2_uniform(pass: &EffectPass, uniform: &str) -> Result<[f32; 2], EffectsError> {
let Some(value) = pass.uniforms.get(uniform) else {
return Err(EffectsError::MissingUniform {
@ -328,3 +380,103 @@ fn read_vec2_uniform(pass: &EffectPass, uniform: &str) -> Result<[f32; 2], Effec
}
Ok([values[0], values[1]])
}
fn pack_gaussian_blur(
pass: &EffectPass,
width: u32,
height: u32,
) -> Result<GenericUniformBuffer, EffectsError> {
let sigma = read_number_uniform(pass, "u_sigma")?;
let step = read_number_uniform(pass, "u_step")?;
let direction = read_vec2_uniform(pass, "u_direction")?;
let mut buf = GenericUniformBuffer::default();
buf.resolution = [width as f32, height as f32];
buf.scalars[0][0] = sigma;
buf.scalars[0][1] = step;
buf.vectors[0][0] = direction[0];
buf.vectors[0][1] = direction[1];
Ok(buf)
}
fn pack_brightness_contrast(
pass: &EffectPass,
width: u32,
height: u32,
) -> Result<GenericUniformBuffer, EffectsError> {
let brightness = read_number_uniform_or(pass, "u_brightness", 0.0);
let contrast = read_number_uniform_or(pass, "u_contrast", 1.0);
let mut buf = GenericUniformBuffer::default();
buf.resolution = [width as f32, height as f32];
buf.scalars[0][0] = brightness;
buf.scalars[0][1] = contrast;
Ok(buf)
}
fn pack_grayscale(
pass: &EffectPass,
width: u32,
height: u32,
) -> Result<GenericUniformBuffer, EffectsError> {
let intensity = read_number_uniform_or(pass, "u_intensity", 1.0);
let mut buf = GenericUniformBuffer::default();
buf.resolution = [width as f32, height as f32];
buf.scalars[0][0] = intensity;
Ok(buf)
}
fn pack_saturation(
pass: &EffectPass,
width: u32,
height: u32,
) -> Result<GenericUniformBuffer, EffectsError> {
let saturation = read_number_uniform_or(pass, "u_saturation", 1.0);
let mut buf = GenericUniformBuffer::default();
buf.resolution = [width as f32, height as f32];
buf.scalars[0][0] = saturation;
Ok(buf)
}
fn pack_sepia(
pass: &EffectPass,
width: u32,
height: u32,
) -> Result<GenericUniformBuffer, EffectsError> {
let intensity = read_number_uniform_or(pass, "u_intensity", 1.0);
let mut buf = GenericUniformBuffer::default();
buf.resolution = [width as f32, height as f32];
buf.scalars[0][0] = intensity;
Ok(buf)
}
fn pack_invert(
pass: &EffectPass,
width: u32,
height: u32,
) -> Result<GenericUniformBuffer, EffectsError> {
let intensity = read_number_uniform_or(pass, "u_intensity", 1.0);
let mut buf = GenericUniformBuffer::default();
buf.resolution = [width as f32, height as f32];
buf.scalars[0][0] = intensity;
Ok(buf)
}
fn pack_vignette(
pass: &EffectPass,
width: u32,
height: u32,
) -> Result<GenericUniformBuffer, EffectsError> {
let radius = read_number_uniform_or(pass, "u_radius", 0.75);
let softness = read_number_uniform_or(pass, "u_softness", 0.35);
let mut buf = GenericUniformBuffer::default();
buf.resolution = [width as f32, height as f32];
buf.scalars[0][0] = radius;
buf.scalars[0][1] = softness;
Ok(buf)
}

View File

@ -0,0 +1,26 @@
struct VertexOutput {
@builtin(position) position: vec4f,
@location(0) tex_coord: vec2f,
}
struct EffectUniforms {
resolution: vec2f,
_pad_res: vec2f,
scalars: array<vec4f, 2>,
vectors: array<vec4f, 2>,
}
@group(0) @binding(0) var input_texture: texture_2d<f32>;
@group(0) @binding(1) var input_sampler: sampler;
@group(1) @binding(0) var<uniform> uniforms: EffectUniforms;
@fragment
fn fragment_main(input: VertexOutput) -> @location(0) vec4f {
let color = textureSample(input_texture, input_sampler, input.tex_coord);
let brightness = uniforms.scalars[0].x;
let contrast = uniforms.scalars[0].y;
let adjusted = (color.rgb - 0.5) * contrast + 0.5 + brightness;
return vec4f(clamp(adjusted, vec3f(0.0), vec3f(1.0)), color.a);
}

View File

@ -5,8 +5,9 @@ struct VertexOutput {
struct EffectUniforms {
resolution: vec2f,
direction: vec2f,
scalars: vec4f,
_pad_res: vec2f,
scalars: array<vec4f, 2>,
vectors: array<vec4f, 2>,
}
@group(0) @binding(0) var input_texture: texture_2d<f32>;
@ -16,8 +17,9 @@ struct EffectUniforms {
@fragment
fn fragment_main(input: VertexOutput) -> @location(0) vec4f {
let texel_size = vec2f(1.0, 1.0) / uniforms.resolution;
let sigma = uniforms.scalars.x;
let step_size = uniforms.scalars.y;
let sigma = uniforms.scalars[0].x;
let step_size = uniforms.scalars[0].y;
let direction = uniforms.vectors[0].xy;
var color = vec4f(0.0, 0.0, 0.0, 0.0);
var total_weight = 0.0;
@ -25,7 +27,7 @@ fn fragment_main(input: VertexOutput) -> @location(0) vec4f {
for (var index = -30; index <= 30; index = index + 1) {
let position = f32(index) * step_size;
let weight = exp(-(position * position) / (2.0 * sigma * sigma));
let sample_uv = input.tex_coord + (texel_size * uniforms.direction * position);
let sample_uv = input.tex_coord + (texel_size * direction * position);
color = color + textureSample(input_texture, input_sampler, sample_uv) * weight;
total_weight = total_weight + weight;
}

View File

@ -0,0 +1,28 @@
struct VertexOutput {
@builtin(position) position: vec4f,
@location(0) tex_coord: vec2f,
}
struct EffectUniforms {
resolution: vec2f,
_pad_res: vec2f,
scalars: array<vec4f, 2>,
vectors: array<vec4f, 2>,
}
@group(0) @binding(0) var input_texture: texture_2d<f32>;
@group(0) @binding(1) var input_sampler: sampler;
@group(1) @binding(0) var<uniform> uniforms: EffectUniforms;
@fragment
fn fragment_main(input: VertexOutput) -> @location(0) vec4f {
let color = textureSample(input_texture, input_sampler, input.tex_coord);
let intensity = uniforms.scalars[0].x;
let luminance = dot(color.rgb, vec3f(0.2126, 0.7152, 0.0722));
let gray = vec3f(luminance);
let result = mix(color.rgb, gray, intensity);
return vec4f(result, color.a);
}

View File

@ -0,0 +1,26 @@
struct VertexOutput {
@builtin(position) position: vec4f,
@location(0) tex_coord: vec2f,
}
struct EffectUniforms {
resolution: vec2f,
_pad_res: vec2f,
scalars: array<vec4f, 2>,
vectors: array<vec4f, 2>,
}
@group(0) @binding(0) var input_texture: texture_2d<f32>;
@group(0) @binding(1) var input_sampler: sampler;
@group(1) @binding(0) var<uniform> uniforms: EffectUniforms;
@fragment
fn fragment_main(input: VertexOutput) -> @location(0) vec4f {
let color = textureSample(input_texture, input_sampler, input.tex_coord);
let intensity = uniforms.scalars[0].x;
let inverted = vec3f(1.0 - color.r, 1.0 - color.g, 1.0 - color.b);
let result = mix(color.rgb, inverted, intensity);
return vec4f(result, color.a);
}

View File

@ -0,0 +1,28 @@
struct VertexOutput {
@builtin(position) position: vec4f,
@location(0) tex_coord: vec2f,
}
struct EffectUniforms {
resolution: vec2f,
_pad_res: vec2f,
scalars: array<vec4f, 2>,
vectors: array<vec4f, 2>,
}
@group(0) @binding(0) var input_texture: texture_2d<f32>;
@group(0) @binding(1) var input_sampler: sampler;
@group(1) @binding(0) var<uniform> uniforms: EffectUniforms;
@fragment
fn fragment_main(input: VertexOutput) -> @location(0) vec4f {
let color = textureSample(input_texture, input_sampler, input.tex_coord);
let saturation = uniforms.scalars[0].x;
let luminance = dot(color.rgb, vec3f(0.2126, 0.7152, 0.0722));
let gray = vec3f(luminance);
let result = mix(gray, color.rgb, saturation);
return vec4f(result, color.a);
}

View File

@ -0,0 +1,30 @@
struct VertexOutput {
@builtin(position) position: vec4f,
@location(0) tex_coord: vec2f,
}
struct EffectUniforms {
resolution: vec2f,
_pad_res: vec2f,
scalars: array<vec4f, 2>,
vectors: array<vec4f, 2>,
}
@group(0) @binding(0) var input_texture: texture_2d<f32>;
@group(0) @binding(1) var input_sampler: sampler;
@group(1) @binding(0) var<uniform> uniforms: EffectUniforms;
@fragment
fn fragment_main(input: VertexOutput) -> @location(0) vec4f {
let color = textureSample(input_texture, input_sampler, input.tex_coord);
let intensity = uniforms.scalars[0].x;
let r = min(color.r * 0.393 + color.g * 0.769 + color.b * 0.189, 1.0);
let g = min(color.r * 0.349 + color.g * 0.686 + color.b * 0.168, 1.0);
let b = min(color.r * 0.272 + color.g * 0.534 + color.b * 0.131, 1.0);
let sepia = vec3f(r, g, b);
let result = mix(color.rgb, sepia, intensity);
return vec4f(result, color.a);
}

View File

@ -0,0 +1,29 @@
struct VertexOutput {
@builtin(position) position: vec4f,
@location(0) tex_coord: vec2f,
}
struct EffectUniforms {
resolution: vec2f,
_pad_res: vec2f,
scalars: array<vec4f, 2>,
vectors: array<vec4f, 2>,
}
@group(0) @binding(0) var input_texture: texture_2d<f32>;
@group(0) @binding(1) var input_sampler: sampler;
@group(1) @binding(0) var<uniform> uniforms: EffectUniforms;
@fragment
fn fragment_main(input: VertexOutput) -> @location(0) vec4f {
let color = textureSample(input_texture, input_sampler, input.tex_coord);
let radius = uniforms.scalars[0].x;
let softness = uniforms.scalars[0].y;
let center = input.tex_coord - vec2f(0.5);
let dist = length(center);
let edge = smoothstep(radius, radius - softness, dist);
return vec4f(color.rgb * edge, color.a);
}