common.vert

#version 300 es

layout (location = 0) in vec3 aVertexPosition;
layout (location = 1) in vec2 aVertexTextureCoords;

out vec2 vTextureCoords;

void main() {
  vTextureCoords = aVertexTextureCoords;
  gl_Position = vec4(aVertexPosition, 1.0);
}

edge.frag

#version 300 es

precision highp float;

const float R_LUMINANCE = 0.298912;
const float G_LUMINANCE = 0.586611;
const float B_LUMINANCE = 0.114478;

// グレースケール化
float toMonochrome(vec3 color) {
  return dot(color, vec3(R_LUMINANCE, G_LUMINANCE, B_LUMINANCE));
}

// form from http://lolengine.net/blog/2013/07/27/rgb-to-hsv-in-glsl
vec3 rgb2hsv(vec3 c) {
  vec4 K = vec4(0.0, -1.0 / 3.0, 2.0 / 3.0, -1.0);
  vec4 p = mix(vec4(c.bg, K.wz), vec4(c.gb, K.xy), step(c.b, c.g));
  vec4 q = mix(vec4(p.xyw, c.r), vec4(c.r, p.yzx), step(p.x, c.r));

  float d = q.x - min(q.w, q.y);
  float e = 1.0e-10;
  return vec3(abs(q.z + (q.w - q.y) / (6.0 * d + e)), d / (q.x + e), q.x);
}

// fork from https://www.shadertoy.com/view/MsS3Wc
vec3 hsv2rgb(vec3 color) {
  // Hueを[0, 1]から[0, 6]へスケール
  float hue = color.x * 6.0;
  
  vec3 m = mod(hue + vec3(0.0, 4.0, 2.0), 6.0);
  vec3 a = abs(m - 3.0);
  vec3 rgb = clamp(a - 1.0, 0.0, 1.0);
    
  // 白とrgbを彩度(動径)に沿って補間したものに明度をかける
  return color.z * mix(vec3(1.0), rgb, color.y);
}

float posterizeHue(float hue, int level) {
  float hueStep = 255.0 / float(level - 1);
  
  float newHue = hue * 360.0;
  
  newHue = floor(newHue / hueStep + 0.5) * hueStep;
  newHue /= 360.0;
  
  return newHue;
}

float posterizeColorRatio(float ratio, int level) {
  float ratioStep = 255.0 / float(level - 1);
  
  float unclamp = ratio * 255.0;
  float newRatio = floor(unclamp / ratioStep + 0.5) * ratioStep;
  newRatio /= 255.0;
  
  return newRatio;
}

vec2[9] offset3x3(vec2 texelSize) {
  vec2 offset[9];
  
  offset[0] = vec2(-texelSize.x, -texelSize.y);
  offset[1] = vec2( 0.0, -texelSize.y);
  offset[2] = vec2( texelSize.x, -texelSize.y);
  offset[3] = vec2(-texelSize.x, 0.0);
  offset[4] = vec2( 0.0, 0.0);
  offset[5] = vec2( texelSize.x, 0.0);
  offset[6] = vec2(-texelSize.x, texelSize.y);
  offset[7] = vec2( 0.0, texelSize.y);
  offset[8] = vec2( texelSize.x, 1.0);
  
  return offset;
}

vec3 offsetLookup(sampler2D tex, vec2 center, vec2 offset) {
  return texture(tex, center + offset).rgb;
}

vec3 applyKernelXY(sampler2D tex, vec2 texelSize, vec2 center, float[9] kernelX, float[9] kernelY) {
  vec2[9] offset = offset3x3(texelSize);
  
  float dx = 0.0;
  float dy = 0.0;
  
  vec3 color;
  float el;
  
  for (int i = 0; i < 9; i++) {
    color = offsetLookup(tex, center, offset[i]);
    el = rgb2hsv(color).b; // 明度
    
    dx += el * kernelX[i];
    dy += el * kernelY[i];
  }
  
  float result = length(vec2(dx, dy));
  
  return vec3(result);
}

// common
uniform sampler2D uTexture0;
// for level
uniform float uGamma;
uniform float uHue;
uniform float uSaturation;
uniform float uBrightness;
uniform int uLevelH;
uniform int uLevelS;
uniform int uLevelB;
uniform vec3 uMinDensity;

in vec2 vTextureCoords;

layout (location = 0) out vec4 fragColor1;
layout (location = 1) out vec4 fragColor2;

void main() {
  ivec2 textureSize = textureSize(uTexture0, 0);
  vec2 texelSize = 1.0 / vec2(float(textureSize.x), float(textureSize.y));
  
  vec2 texCoord = vec2(vTextureCoords.x, 1.0 - vTextureCoords.y);
  vec3 inputColor = texture(uTexture0, texCoord).rgb;
  
  /* level -------------------------------------- */
  
  // 輝度調整
  vec3 outColor1 = pow(inputColor, vec3(uGamma));
  
  vec3 hsv = rgb2hsv(outColor1);
  
  // HSVによる階調数低減
  hsv.r = uLevelH == 1 ? uHue / 360.0 : posterizeHue(hsv.r, uLevelH);
  hsv.g = uLevelS == 1 ? uSaturation : posterizeColorRatio(hsv.g, uLevelS);
  hsv.b = uLevelB == 1 ? uBrightness : posterizeColorRatio(hsv.b, uLevelB);
  
  outColor1 = hsv2rgb(hsv);
  outColor1 = mix(outColor1, vec3(1.0), uMinDensity);
  
  /* edge --------------------------------------- */
  
  // prewittフィルタ
  float[9] kernelX = float[](
    -1.0, 0.0, 1.0,
    -1.0, 0.0, 1.0,
    -1.0, 0.0, 1.0
  );
  float[9] kernelY = float[](
    -1.0, -1.0, -1.0,
    0.0, 0.0, 0.0,
    1.0, 1.0, 1.0
  );
  
  // エッジ抽出
  vec3 outColor2 = applyKernelXY(uTexture0, texelSize, texCoord, kernelX, kernelY);
  outColor2 = vec3(toMonochrome(outColor2));
  
  /* result ------------------------------------- */
  
  fragColor1 = vec4(outColor1, 1.0);
  fragColor2 = vec4(outColor2, 1.0);
}

common.vert

#version 300 es

layout (location = 0) in vec3 aVertexPosition;
layout (location = 1) in vec2 aVertexTextureCoords;

out vec2 vTextureCoords;

void main() {
  vTextureCoords = aVertexTextureCoords;
  gl_Position = vec4(aVertexPosition, 1.0);
}

draw-stroke.frag

#version 300 es

precision highp float;

// 符号なし整数の最大値
const uint UINT_MAX = 0xffffffffu;

// 算術積に使う大きな桁数の定数
uvec3 k = uvec3(0x456789abu, 0x6789ab45u, 0x89ab4567u);
// シフト数
uvec3 u = uvec3(1, 2, 3);

// 符号なし整数の2d => 2dハッシュ関数
uvec2 uhash22(uvec2 n){
  n ^= (n.yx << u.xy);
  n ^= (n.yx >> u.xy);
  n *= k.xy;
  n ^= (n.yx << u.xy);
  return n * k.xy;
}

// 浮動小数点数の2d => 1dハッシュ関数
float hash21(vec2 b) {
  // ビット列を符号なし整数に変換
  uvec2 n = floatBitsToUint(b);
  // 値の正規化
  return float(uhash22(n).x) / float(UINT_MAX);
}

float hash21Clamp(vec2 b, float minV, float maxV) {
  return ((maxV - minV) * hash21(b)) + minV;
}

vec3 lighten(vec3 b, vec3 f) {
  return max(b, f);
}

vec3 screen(vec3 b, vec3 f) {
  return 1.0 - (1.0 - b) * (1.0 - f);
}

vec3 overlay(vec3 b, vec3 f) {
  float brightness = max(b.r, max(b.g, b.b));
  
  return mix(
    2.0 * b * f,
    1.0 - 2.0 * (1.0 - b) * (1.0 - f),
    step(0.5, brightness)
  );
}

vec3 colorburn(vec3 b, vec3 f) {
  return 1.0 - (1.0 - b) / f;
}

uniform sampler2D uTexture1; // edge
uniform sampler2D uTexture3; // posterized
uniform float uDepthStroke;
uniform int uBlendMode;
uniform bool uColored;

in vec2 vTextureCoords;

out vec4 fragColor;

void main() {
  vec2 texCoord = vec2(vTextureCoords.x, 1.0 - vTextureCoords.y);
  
  vec3 posterized = texture(uTexture3, texCoord).rgb;
  vec4 edge = texture(uTexture1, texCoord);
  
  float lineColor = edge.r + hash21Clamp(texCoord, -uDepthStroke, uDepthStroke);
  
  vec3 pencil = vec3(edge) * vec3(lineColor);
  pencil = vec3(1.0) - pencil;
  
  vec3 outColor = vec3(0.0);
  
  if (uBlendMode == 0) {
    outColor = uColored ? pencil * posterized : pencil;
  } else if (uBlendMode == 1) {
    outColor = uColored ? lighten(pencil, posterized) : pencil;
  } else if (uBlendMode == 2) {
    outColor = uColored ? overlay(posterized, pencil) : pencil;
  } else if (uBlendMode == 3) {
    outColor = uColored ? screen(pencil, posterized) : pencil;
  } else if (uBlendMode == 4) {
    outColor = uColored ? colorburn(posterized, pencil) : pencil;
  }
  
  fragColor = vec4(outColor, 1.0);
}

render.ts

import { SketchFilter, type FilterSketchConfig, type FilterSketchFn } from "sketchgl"
import { Uniforms, Program } from "sketchgl/program"
import type { RawVector3 } from "sketchgl/math"
import { ImageTexture } from "sketchgl/texture"
import { MRTRenderer } from "sketchgl/renderer"
import { CanvasCoverPolygon } from "sketchgl/geometry"

import vertSrc from "./common.vert?raw"
import fragSrcForEdge from "./edge.frag?raw"
import fragSrcForDrawStroke from "./draw-stroke.frag?raw"

import imageCubeLogo from "@/assets/original/pastel-tomixy.png"
import imageGoldfishBowl from "@/assets/original/japanese-style_00011.jpg"
import imageAutumnLeaves from "@/assets/original/autumn-leaves_00037.jpg"
import imageTree from "@/assets/original/tree-woods_00123.jpg"

const sketch: FilterSketchFn = ({ gl, canvas, fitImage }) => {
  const uniformsFor = {
    level: new Uniforms(gl, [
      "uGamma",
      "uHue",
      "uSaturation",
      "uBrightness",
      "uLevelH",
      "uLevelS",
      "uLevelB",
      "uMinDensity"
    ]),
    drawStroke: new Uniforms(gl, ["uTexture1", "uTexture2", "uTexture3", "uColored", "uDepthStroke", "uBlendMode"])
  }

  const blendModes = ["multiply", "lighten", "overlay", "screen", "colorburn"]
  let uBlendMode = 0
  let uGamma = 1.0
  let uHue = 0.0
  let uSaturation = 1.0
  let uBrightness = 1.0
  let uLevelH = 256
  let uLevelS = 2
  let uLevelB = 128
  let uMinDensity: RawVector3 = [0.3, 0.3, 0.3]
  let uColored = true
  let uDepthStroke = 1.0

  const images = [
    { name: "木", src: imageTree },
    { name: "立方体ロゴ", src: imageCubeLogo },
    { name: "金魚鉢", src: imageGoldfishBowl },
    { name: "紅葉", src: imageAutumnLeaves }
  ]
  const imageNames = images.map((obj) => obj.name)
  const textures = images.map((img) => new ImageTexture(gl, img.src))
  let activeImage = 2

  const renderer = new MRTRenderer(gl, canvas, vertSrc, fragSrcForEdge, { texCount: 2 })
  uniformsFor.level.init(renderer.glProgramForOffscreen)

  const program = new Program(gl)
  program.attach(vertSrc, fragSrcForDrawStroke)
  uniformsFor.drawStroke.init(program.glProgram)

  const plane = new CanvasCoverPolygon(gl)
  plane.setLocations({ vertices: 0, uv: 1 })

  gl.clearColor(1.0, 1.0, 1.0, 1.0)
  gl.clearDepth(1.0)

  return {
    resize: [renderer.resize],

    preload: [...textures.map((tex) => tex.load())],
    preloaded: [() => fitImage(textures[activeImage].img)],

    drawOnFrame() {
      gl.viewport(0, 0, gl.canvas.width, gl.canvas.height)
      plane.bind()

      /* to Offscreens(エッジ抽出 / 輝度調整・階調数低減) ------------------------ */

      renderer.switchToOffcanvas()
      gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)

      uniformsFor.level.float("uGamma", uGamma)
      uniformsFor.level.float("uHue", uHue)
      uniformsFor.level.float("uSaturation", uSaturation)
      uniformsFor.level.float("uBrightness", uBrightness)
      uniformsFor.level.int("uLevelH", uLevelH)
      uniformsFor.level.int("uLevelS", uLevelS)
      uniformsFor.level.int("uLevelB", uLevelB)
      uniformsFor.level.fvector3("uMinDensity", uMinDensity)

      textures[activeImage].activate(renderer.glProgramForOffscreen!, "uTexture0")
      plane.draw({ primitive: "TRIANGLES" })

      /* to Canvas(ストローク描画) ----------------------- */

      renderer.switchToCanvas(program)
      gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)

      uniformsFor.drawStroke.bool("uColored", uColored)
      uniformsFor.drawStroke.float("uDepthStroke", uDepthStroke)
      uniformsFor.drawStroke.int("uBlendMode", uBlendMode)

      renderer.useAsTexture(0, "uTexture3", program.glProgram)
      renderer.useAsTexture(1, "uTexture1", program.glProgram)
      plane.draw({ primitive: "TRIANGLES" })
    },

    control(ui) {
      ui.select("Image", imageNames[activeImage], imageNames, (name) => {
        const idx = imageNames.indexOf(name)
        if (idx < 0) return
        activeImage = idx
        fitImage(textures[activeImage].img)
      })
      ui.number("Gamma", uGamma, 0.0, 2.0, 0.01, (value) => {
        uGamma = value
      })
      ui.number("HSV.h", uHue, 0, 360, 1, (value) => {
        uHue = value
      })
      ui.number("HSV.s", uSaturation, 0.0, 1.0, 0.01, (value) => {
        uSaturation = value
      })
      ui.number("HSV.v", uBrightness, 0.0, 1.0, 0.01, (value) => {
        uBrightness = value
      })
      ui.number("Level HSV.h", uLevelH, 1, 256, 1, (value) => {
        uLevelH = value
      })
      ui.number("Level HSV.s", uLevelS, 1, 256, 1, (value) => {
        uLevelS = value
      })
      ui.number("Level HSV.v", uLevelB, 1, 256, 1, (value) => {
        uLevelB = value
      })
      ui.rgb("Min Density", uMinDensity, (color) => {
        uMinDensity = color
      })
      ui.number("線の濃さ", uDepthStroke, 0.0, 2.0, 0.01, (value) => {
        uDepthStroke = value
      })
      ui.boolean("Color", uColored, (isActive) => {
        uColored = isActive
      })
      ui.select("Blend Mode", blendModes[uBlendMode], blendModes, (name) => {
        const idx = blendModes.indexOf(name)
        if (idx < 0) return
        uBlendMode = idx
      })
    }
  }
}

export const onload = () => {
  const config: FilterSketchConfig = {
    canvas: {
      el: "gl-canvas",
      autoResize: true
    }
  }
  SketchFilter.init(config, sketch)
}