Filters
Nine image filters implemented as in-memory NSL shaders.
local natiny = require("natiny")
local demo = require("common.modules.demo")
local IMAGE_PATH = "data/common/textures/saikosha.jpg"
local IMAGE_HEIGHT_FRACTION = 2.0 / 3.0
local WINDOW_WIDTH, WINDOW_HEIGHT = 1280, 720
local LIST = {
x = 16, y = 80,
line_height = 20,
padding = 8,
corner_radius = 10,
background_alpha = 0.65,
}
local BG = { 21 / 255, 30 / 255, 44 / 255 } -- #151E2C
local OBJECT = { 169 / 255, 179 / 255, 197 / 255 } -- #A9B3C5
local LIGHT_TEXT = { 243 / 255, 245 / 255, 250 / 255 } -- #F3F5FA
local SHADER_PREAMBLE = [[
attribute vec3 position;
attribute vec2 texcoord0;
attribute vec4 color0;
varying vec2 uv;
varying vec4 tint;
uniform mat4 mtx_worldviewproj;
uniform sampler2D tex0;
uniform vec4 image_size;
uniform vec4 clock;
vec3 grab(vec2 p)
{
return texture(tex0, clamp(p, vec2(0.0), vec2(1.0))).rgb;
}
void vertex()
{
uv = texcoord0;
tint = color0;
POSITION = mtx_worldviewproj * vec4(position, 1.0);
}
]]
local LUMA = "const vec3 LUMA = vec3(0.2126, 0.7152, 0.0722);\n"
local FILTERS = {
{
name = "Original",
note = "the picture as it was decoded, no shader maths at all",
fragment = [[
void fragment()
{
COLOR = vec4(grab(uv), 1.0) * tint;
}
]],
},
{
name = "Grayscale",
note = "every colour collapsed onto its own brightness",
fragment = LUMA .. [[
void fragment()
{
float y = dot(grab(uv), LUMA);
COLOR = vec4(vec3(y), 1.0) * tint;
}
]],
},
{
name = "Sepia",
note = "the same brightness, run between a cold shadow and a warm light",
fragment = LUMA .. [[
const vec3 SHADOW = vec3(0.16, 0.10, 0.06);
const vec3 LIGHT = vec3(1.00, 0.89, 0.68);
void fragment()
{
float y = dot(grab(uv), LUMA);
COLOR = vec4(mix(SHADOW, LIGHT, y), 1.0) * tint;
}
]],
},
{
name = "Negative",
note = "each channel turned inside out, the film before it is printed",
fragment = [[
void fragment()
{
COLOR = vec4(1.0 - grab(uv), 1.0) * tint;
}
]],
},
{
texel = true,
name = "Blur",
note = "81 taps on a round falloff, the whole kernel in one pass",
fragment = [[
const int RADIUS = 4;
const float STEP = 2.0;
void fragment()
{
vec2 texel = image_size.zw;
vec3 total = vec3(0.0);
float weight = 0.0;
for (int sy = -RADIUS; sy <= RADIUS; ++sy) {
for (int sx = -RADIUS; sx <= RADIUS; ++sx) {
vec2 offset = vec2(float(sx), float(sy));
float reach = length(offset) / float(RADIUS + 1);
float w = smoothstep(1.0, 0.0, reach);
total += grab(uv + offset * texel * STEP) * w;
weight += w;
}
}
COLOR = vec4(total / weight, 1.0) * tint;
}
]],
},
{
texel = true,
name = "Edges",
note = "a Sobel gradient, the picture reduced to where it changes",
fragment = LUMA .. [[
const float SPREAD = 1.5;
const float GAIN = 1.6;
const vec3 PAPER = vec3(21.0 / 255.0, 30.0 / 255.0, 44.0 / 255.0);
const vec3 LINE = vec3(248.0 / 255.0, 249.0 / 255.0, 253.0 / 255.0);
float brightness(vec2 p)
{
return dot(grab(p), LUMA);
}
void fragment()
{
vec2 t = image_size.zw * SPREAD;
float tl = brightness(uv + vec2(-t.x, -t.y));
float tm = brightness(uv + vec2( 0.0, -t.y));
float tr = brightness(uv + vec2( t.x, -t.y));
float ml = brightness(uv + vec2(-t.x, 0.0));
float mr = brightness(uv + vec2( t.x, 0.0));
float bl = brightness(uv + vec2(-t.x, t.y));
float bm = brightness(uv + vec2( 0.0, t.y));
float br = brightness(uv + vec2( t.x, t.y));
float gx = (tr + 2.0 * mr + br) - (tl + 2.0 * ml + bl);
float gy = (bl + 2.0 * bm + br) - (tl + 2.0 * tm + tr);
float edge = clamp(sqrt(gx * gx + gy * gy) * GAIN, 0.0, 1.0);
COLOR = vec4(mix(PAPER, LINE, edge), 1.0) * tint;
}
]],
},
{
name = "Fisheye",
note = "the sample point pulled toward the middle, so the middle swells",
fragment = [[
const float BULGE = 0.45;
void fragment()
{
vec2 centred = uv - 0.5;
float r2 = clamp(dot(centred, centred) * 4.0, 0.0, 1.0);
COLOR = vec4(grab(centred * (1.0 - BULGE * (1.0 - r2)) + 0.5), 1.0) * tint;
}
]],
},
{
name = "Lens",
note = "the three channels pulled apart toward the rim, and a vignette",
fragment = [[
const float FRINGE = 0.014;
const float VIGNETTE = 0.80;
void fragment()
{
vec2 centred = uv - 0.5;
float r = length(centred) * 2.0;
vec2 split = centred * FRINGE * r;
float red = grab(uv + split).r;
float green = grab(uv).g;
float blue = grab(uv - split).b;
float fall = 1.0 - VIGNETTE * smoothstep(0.35, 1.30, r);
COLOR = vec4(vec3(red, green, blue) * fall, 1.0) * tint;
}
]],
},
{
clock = true,
name = "Ripple",
note = "rings running out from the middle, the only filter that moves",
fragment = [[
const float RINGS = 22.0;
const float AMPLITUDE = 0.014;
const float SPEED = 2.4;
void fragment()
{
vec2 centred = uv - 0.5;
float r = length(centred);
float wave = sin(r * RINGS - clock.x * SPEED);
float fade = 1.0 - smoothstep(0.0, 0.72, r);
vec2 push = centred / max(r, 1e-4) * wave * AMPLITUDE * fade;
COLOR = vec4(grab(uv + push) * (1.0 + wave * 0.12 * fade), 1.0) * tint;
}
]],
},
{
texel = true,
name = "Mosaic",
note = "one sample per cell, the cells kept square in the image",
fragment = [[
const float CELLS = 46.0;
void fragment()
{
vec2 grid = vec2(CELLS, CELLS * image_size.y / image_size.x);
COLOR = vec4(grab((floor(uv * grid) + 0.5) / grid), 1.0) * tint;
}
]],
},
}
if not natiny.backend.init(arg and arg[1] or natiny.backend.AUTO) then
os.exit(1)
end
local window = natiny.window.create("Natiny - filters", WINDOW_WIDTH, WINDOW_HEIGHT)
natiny.window.set_scale_mode(window, natiny.window.SCALE_MODE_STRETCH)
-- One picture is the whole of this demo's data. The shell fetches it and
-- keeps the window until it is here; the shaders below are strings and cost
-- nothing to wait for.
local image, image_width, image_height
local function build(filter)
local source = natiny.resource.from_data(SHADER_PREAMBLE .. filter.fragment)
local shader = natiny.shader.load(source)
natiny.resource.destroy(source)
filter.shader = shader
filter.material = natiny.material.create(shader)
if filter.texel then
natiny.material.set_constant(filter.material, "image_size",
image_width, image_height,
1.0 / image_width, 1.0 / image_height)
end
end
local KEYS = {
natiny.input.KEY_0, natiny.input.KEY_1, natiny.input.KEY_2,
natiny.input.KEY_3, natiny.input.KEY_4, natiny.input.KEY_5,
natiny.input.KEY_6, natiny.input.KEY_7, natiny.input.KEY_8,
natiny.input.KEY_9,
}
local active = 1
local function draw_filter_list()
local font = demo:plate_font()
local header = "Press a number"
local width = natiny.font.measure_text(font, header)
for index, filter in ipairs(FILTERS) do
local line = string.format("%d %s", index - 1, filter.name)
width = math.max(width, natiny.font.measure_text(font, line))
end
natiny.render.set_color(0, 0, 0, LIST.background_alpha)
natiny.render.rounded_rectangle(
LIST.x - LIST.padding,
LIST.y - LIST.padding,
width + LIST.padding * 2,
(#FILTERS + 1) * LIST.line_height + LIST.padding * 2,
LIST.corner_radius
)
natiny.render.set_color(LIGHT_TEXT[1], LIGHT_TEXT[2], LIGHT_TEXT[3], 1)
natiny.render.text(font, LIST.x, LIST.y, header)
for index, filter in ipairs(FILTERS) do
local ink = LIGHT_TEXT
local alpha = index == active and 1.0 or 0.65
natiny.render.set_color(ink[1], ink[2], ink[3], alpha)
natiny.render.text(font, LIST.x, LIST.y + index * LIST.line_height,
string.format("%d %s", index - 1, filter.name))
end
end
local time = 0.0
local function frame(dt)
natiny.window.bind(window, function()
time = time + dt
for index, key in ipairs(KEYS) do
if natiny.input.get_key_pressed(key) then active = index end
end
local filter = FILTERS[active]
demo:set_user_text(string.format("%d %s - %s", active - 1, filter.name, filter.note))
if filter.clock then
natiny.material.set_constant(filter.material, "clock", time, 0, 0, 0)
end
local window_width = natiny.window.get_width(window)
local window_height = natiny.window.get_height(window)
local height = window_height * IMAGE_HEIGHT_FRACTION
local width = height * image_width / image_height
natiny.render.clear(BG[1], BG[2], BG[3], 1)
natiny.render.set_color(1, 1, 1, 1)
natiny.material.bind(filter.material, function()
natiny.render.texture(image,
(window_width - width) * 0.5,
(window_height - height) * 0.5,
width, height)
end)
draw_filter_list()
end)
end
demo:run({
window = window,
title = "Filters",
resources = { IMAGE_PATH },
-- The picture is in, so the filters can be compiled against its size.
on_loaded = function()
image = natiny.texture.create(demo:resource(IMAGE_PATH))
image_width = natiny.texture.get_width(image)
image_height = natiny.texture.get_height(image)
for _, filter in ipairs(FILTERS) do
build(filter)
end
print(string.format("filters: %d - %s: %d x %d",
#FILTERS, IMAGE_PATH, image_width, image_height))
end,
loop = frame,
})
natiny.backend.shutdown()