index.vert

#version 300 es

in vec2 aVertexTextureCoords;
in vec3 aVertexPosition;

out vec2 vTextureCoords;

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

index.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);
}

uniform sampler2D uTexture0;
uniform float uThreshEdge;
uniform float uGamma;
uniform float uHue;
uniform float uSaturation;
uniform float uBrightness;
uniform int uLevelH;
uniform int uLevelS;
uniform int uLevelB;
uniform bool uGrayScaleOn;
uniform vec3 uMinDensity;

in vec2 vTextureCoords;

out vec4 fragColor;

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;
  
  /* Layer. 1 ----------------------------------- */
  
  // 輝度調整
  vec3 layer1 = pow(inputColor, vec3(uGamma));
  
  vec3 hsv = rgb2hsv(layer1);
  
  // 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);
  
  layer1 = hsv2rgb(hsv);
  layer1 = mix(layer1, vec3(1.0), uMinDensity);
  
  /* Layer. 2 ----------------------------------- */
  
  // 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 layer2 = applyKernelXY(uTexture0, texelSize, texCoord, kernelX, kernelY);
  
  // 反転
  layer2 = vec3(1.0) - layer2;
  
  // 二値化
  layer2 = step(uThreshEdge, layer2);
  layer2 = max(layer2, uMinDensity);
  
  /* Composite ---------------------------------- */
  
  // 合成
  vec3 outColor = layer1 * layer2;
  
  // グレースケール化
  outColor = uGrayScaleOn ? vec3(toMonochrome(outColor)) : outColor;
  
  fragColor = vec4(outColor, 1.0);
}

render.ts

import type { RawVector3 } from "@/lib/math/raw-vector"
import { Space } from "@/lib/canvas/index"
import { Program } from "@/lib/webgl/program"
import { Scene } from "@/lib/webgl/scene"
import { Clock } from "@/lib/event/clock"
import { ControlUi } from "@/lib/gui/control-ui"
import { UniformLoader } from "@/lib/webgl/uniform-loader"
import { Texture } from "@/lib/webgl/texture"

import mainVertSrc from "./index.vert?raw"
import mainFragSrc from "./index.frag?raw"

import imageCubeLogo from "@/assets/original/pastel-tomixy.png"
import imageGoldfishBowl from "@/assets/original/japanese-style_00011.jpg"
import imageUnicorn from "@/assets/original/pair-fantasy-unicorns.jpg"
import imageTree from "@/assets/original/tree-woods_00123.jpg"

export const onload = () => {
  const space = new Space("gl-canvas")
  const canvas = space.canvas
  const gl = space.gl
  if (!canvas || !gl) return

  let scene: Scene
  let program: Program
  let clock: Clock
  let textures: Texture[] = []

  const uniforms = new UniformLoader(gl, [
    "uGrayScaleOn",
    "uThreshEdge",
    "uGamma",
    "uHue",
    "uSaturation",
    "uBrightness",
    "uLevelH",
    "uLevelS",
    "uLevelB",
    "uMinDensity"
  ])

  const images = [
    { name: "木", image: imageTree },
    { name: "立方体ロゴ", image: imageCubeLogo },
    { name: "金魚鉢", image: imageGoldfishBowl },
    { name: "編みぐるみ", image: imageUnicorn }
  ]
  const imageNames = images.map((obj) => obj.name)
  let activeImage = 0

  const defaultThreshEdge = 0.5
  const defaultGamma = 1.0
  const defaultHue = 0.0
  const defaultSaturation = 1.0
  const defaultBrightness = 1.0
  const defaultLevelH = 256
  const defaultLevelS = 16
  const defaultLevelB = 128
  const defaultMinDensity: RawVector3 = [0.3, 0.3, 0.3]
  const defaultGrayScaleOn = false

  const initGuiControls = () => {
    const ui = new ControlUi()
    ui.select("Image", imageNames[activeImage], imageNames, (name) => {
      const idx = imageNames.indexOf(name)
      if (idx < 0) return
      activeImage = idx
      space.fitImage(textures[activeImage].image)
    })
    ui.number("ThreshEdge", defaultThreshEdge, 0.0, 1.0, 0.01, (value) => {
      uniforms.float("uThreshEdge", value)
    })
    ui.number("Gamma", defaultGamma, 0.0, 2.0, 0.01, (value) => {
      uniforms.float("uGamma", value)
    })
    ui.number("HSV.h", defaultHue, 0, 360, 1, (value) => {
      uniforms.float("uHue", value)
    })
    ui.number("HSV.s", defaultSaturation, 0.0, 1.0, 0.01, (value) => {
      uniforms.float("uSaturation", value)
    })
    ui.number("HSV.v", defaultBrightness, 0.0, 1.0, 0.01, (value) => {
      uniforms.float("uBrightness", value)
    })
    ui.number("Level HSV.h", defaultLevelH, 1, 256, 1, (value) => {
      uniforms.int("uLevelH", value)
    })
    ui.number("Level HSV.s", defaultLevelS, 1, 256, 1, (value) => {
      uniforms.int("uLevelS", value)
    })
    ui.number("Level HSV.v", defaultLevelB, 1, 256, 1, (value) => {
      uniforms.int("uLevelB", value)
    })
    ui.rgb("Min Density", defaultMinDensity, (color) => {
      uniforms.fvector3("uMinDensity", color)
    })
    ui.boolean("GrayScale", defaultGrayScaleOn, (isActive) => {
      uniforms.boolean("uGrayScaleOn", isActive)
    })
  }

  const onResize = () => {
    space.fitImage(textures[activeImage].image)
    render()
  }

  const configure = async () => {
    gl.clearColor(1.0, 1.0, 1.0, 1.0)
    gl.clearDepth(1.0)

    program = new Program(gl, mainVertSrc, mainFragSrc)

    scene = new Scene(gl, program)
    clock = new Clock()

    uniforms.init(program)
    uniforms.float("uThreshEdge", defaultThreshEdge)
    uniforms.float("uGamma", defaultGamma)
    uniforms.float("uHue", defaultHue)
    uniforms.float("uSaturation", defaultSaturation)
    uniforms.float("uBrightness", defaultBrightness)
    uniforms.int("uLevelH", defaultLevelH)
    uniforms.int("uLevelS", defaultLevelS)
    uniforms.int("uLevelB", defaultLevelB)
    uniforms.fvector3("uMinDensity", defaultMinDensity)
    uniforms.boolean("uGrayScaleOn", defaultGrayScaleOn)

    await Promise.all(
      images.map(async (obj) => {
        const texture = new Texture(gl, program, obj.image)
        textures.push(texture)
        await texture.load()
      })
    )

    space.fitImage(textures[activeImage].image)
    space.onResize = onResize
  }

  const registerGeometry = () => {
    // 画面を覆う板ポリゴン
    const vertices = [-1.0, 1.0, 0.0, 1.0, 1.0, 0.0, -1.0, -1.0, 0.0, 1.0, -1.0, 0.0]
    const texCoords = [0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 1.0, 1.0]
    const indices = [0, 2, 1, 2, 3, 1]
    scene.add({ vertices, indices, texCoords })
  }

  const render = () => {
    gl.viewport(0, 0, gl.canvas.width, gl.canvas.height)
    gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)

    scene.traverseDraw((obj) => {
      obj.bind()

      textures[activeImage].use()
      gl.drawElements(gl.TRIANGLES, obj.indices.length, gl.UNSIGNED_SHORT, 0)

      obj.cleanup()
    })
  }

  const init = async () => {
    await configure()
    registerGeometry()
    clock.on("tick", render)

    initGuiControls()
  }

  init()
}