Filters

Nine image filters implemented as in-memory NSL shaders.


    
main.lua
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()