Filters

Nine image filters implemented as in-memory NSL shaders.


    
main.lua
local natiny = require("natiny")

local IMAGE_PATH = "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,
}

local BG = natiny.color.from_hex("#151E2C")
local LIGHT_TEXT = natiny.color.from_hex("#F3F5FA")
local LIGHT_TEXT_DIM = natiny.color.from_hex("#F3F5FAA6")
local LIST_BACKGROUND = natiny.color.from_hex("#000000A6")
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)

local image, image_width, image_height
local font

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 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(LIST_BACKGROUND)
    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)
    natiny.render.text(font, LIST.x, LIST.y, header)

    for index, filter in ipairs(FILTERS) do
        natiny.render.set_color(index == active and LIGHT_TEXT or LIGHT_TEXT_DIM)
        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]

        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)

        natiny.render.set_color(natiny.color.from_hex("#FFFFFF"))

        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

local image_resource = natiny.resource.load(IMAGE_PATH)
image = natiny.texture.create(image_resource)
natiny.resource.destroy(image_resource)
image_width = natiny.texture.get_width(image)
image_height = natiny.texture.get_height(image)

local font_resource = natiny.resource.load("common/fonts/roboto.ttf")
font = natiny.font.create(font_resource, 16)
natiny.resource.destroy(font_resource)

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))

natiny.backend.loop(function(dt)
    frame(dt)
end)

natiny.backend.shutdown()