common.vert

#version 300 es

in vec2 aVertexTextureCoords;
in vec3 aVertexPosition;

uniform mat4 uModelViewMatrix;
uniform mat4 uProjectionMatrix;

out vec2 vTextureCoords;

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

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

// 正規分布(ガウス分布)
float gauss(float x, float sigma) {
  float s = sigma * sigma;
  return 1.0 / sqrt(2.0 * s) * exp(-x * x / (2.0 * s));
}

// Gaussianフィルタによる平滑化(横方向)
vec3 xGaussSmooth(sampler2D tex, vec2 uv, vec2 texelSize, float filterSize, float sigma) {
  float weights = 0.0;
  vec3 grad = vec3(0.0);
  
  float h = (filterSize - 1.0) / 2.0;
  
  for (float i = -h; i <= h; ++i) {
    float weight = gauss(i, sigma);
    vec2 offset = vec2(i * texelSize.x, 0.0);
    vec3 color = texture(tex, uv + offset).rgb;
    weights += weight;
    grad += color * weight;
  }
  
  return grad / weights;
}

// Gaussianフィルタによる平滑化(縦方向)
vec3 yGaussSmooth(sampler2D tex, vec2 uv, vec2 texelSize, float filterSize, float sigma) {
  float weights = 0.0;
  vec3 grad = vec3(0.0);
  
  float h = (filterSize - 1.0) / 2.0;
  
  for (float i = -h; i <= h; ++i) {
    float weight = gauss(i, sigma);
    vec2 offset = vec2(0.0, i * texelSize.y);
    vec3 color = texture(tex, uv + offset).rgb;
    weights += weight;
    grad += color * weight;
  }
  
  return grad / weights;
}

uniform sampler2D uTexture0;
uniform int uDirection; // 0: 水平方向, 1: 垂直方向
uniform int uFilterSize;
uniform float uSigma;

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 outColor = uDirection == 0
    ? xGaussSmooth(uTexture0, texCoord, texelSize, float(uFilterSize), uSigma)
    : yGaussSmooth(uTexture0, texCoord, texelSize, float(uFilterSize), uSigma);
  
  outColor = uDirection == 1 ? vec3(toMonochrome(outColor)) : outColor;
  
  fragColor = vec4(outColor, 1.0);
}

common.vert

#version 300 es

in vec2 aVertexTextureCoords;
in vec3 aVertexPosition;

uniform mat4 uModelViewMatrix;
uniform mat4 uProjectionMatrix;

out vec2 vTextureCoords;

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

grad.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;

// 円周率
const float PI = 3.1415926;

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

uniform sampler2D uTexture0;

in vec2 vTextureCoords;

out vec4 fragColor;

void main() {
  vec2 texCoord = vec2(vTextureCoords.x, 1.0 - vTextureCoords.y);
  vec4 gaussian = texture(uTexture0, texCoord);
  
  float dx = dFdx(gaussian.r);
  float dy = dFdy(gaussian.r);
  
  float magnitude = length(vec2(dx, dy));

  fragColor = vec4(dx, dy, magnitude, 1.0);
}

common.vert

#version 300 es

in vec2 aVertexTextureCoords;
in vec3 aVertexPosition;

uniform mat4 uModelViewMatrix;
uniform mat4 uProjectionMatrix;

out vec2 vTextureCoords;

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

canny.frag

#version 300 es

precision highp float;

uniform sampler2D uTexture0;
uniform float uThreshold;

in vec2 vTextureCoords;

out vec4 fragColor;

void main() {
  vec2 texCoord = vec2(vTextureCoords.x, 1.0 - vTextureCoords.y);
  
  vec3 center = texture(uTexture0, texCoord).rgb;
  
  vec3 forward = texture(uTexture0, texCoord + center.xy).rgb;
  vec3 backward = texture(uTexture0, texCoord - center.xy).rgb;
  
  vec3 edge = vec3(0.5);
  vec3 nonEdge = vec3(1.0);
  
  vec3 binary = center.z > forward.z && center.z > backward.z
    ? edge
    : nonEdge;
  
  binary = center.z < uThreshold ? nonEdge : binary;

  fragColor = vec4(binary, 1.0);
}

render.ts

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 { Frame } from "@/lib/webgl/frame"

import vertSrc from "./index.vert?raw"
import fragSrcForGauss from "./gauss.frag?raw"
import fragSrcForGrad from "./grad.frag?raw"
import fragSrcForCanny from "./canny.frag?raw"

import imageMountain from "@/assets/original/mountain_00003.jpg"
import imageGoldfishBowl from "@/assets/original/japanese-style_00011.jpg"
import imageAutumnLeaves from "@/assets/original/autumn-leaves_00037.jpg"
import imageCube from "@/assets/original/darken-purple-tomixy.png"

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[] = []
  let offscreen1: Frame
  let offscreen2: Frame
  let offscreen3: Frame

  const uniformsForGaussX = new UniformLoader(gl, ["uDirection", "uFilterSize", "uSigma"])
  const uniformsForGaussY = new UniformLoader(gl, ["uDirection", "uFilterSize", "uSigma"])
  const uniformsForGrad = new UniformLoader(gl, [])
  const uniformsForCanny = new UniformLoader(gl, ["uThreshold"])

  const images = [
    { name: "立方体ロゴ", image: imageCube },
    { name: "山", image: imageMountain },
    { name: "金魚鉢", image: imageGoldfishBowl },
    { name: "紅葉", image: imageAutumnLeaves }
  ]
  const imageNames = images.map((obj) => obj.name)
  let activeImage = 2

  let sigma = 5.0
  let filterSize = 3
  let threshould = 0.04

  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("標準偏差", sigma, 0.01, 5.0, 0.01, (v) => (sigma = v))
    ui.select("フィルタサイズ", "3x3", ["1x1", "3x3", "5x5"], (v) => (filterSize = ~~v[0]))
    ui.number("閾値", threshould, 0.01, 0.1, 0.001, (v) => (threshould = v))
  }

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

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

    program = new Program(gl, vertSrc, fragSrcForCanny, false)

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

    offscreen1 = new Frame(gl, canvas, vertSrc, fragSrcForGauss, 0)
    offscreen2 = new Frame(gl, canvas, vertSrc, fragSrcForGauss, 0)
    offscreen3 = new Frame(gl, canvas, vertSrc, fragSrcForGrad, 0)

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

    uniformsForGaussX.init(offscreen1.program)
    uniformsForGaussY.init(offscreen2.program)
    uniformsForGrad.init(offscreen3.program)
    uniformsForCanny.init(program)

    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)

    /* to Offscreen (horizontal blur) ------------- */

    gl.bindFramebuffer(gl.FRAMEBUFFER, offscreen1.framebuffer)
    gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)

    offscreen1.program.use()

    uniformsForGaussX.int("uDirection", 0)
    uniformsForGaussX.int("uFilterSize", filterSize)
    uniformsForGaussX.float("uSigma", sigma)

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

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

      obj.cleanup()
    })

    /* to Offscreen (vertical blur) ------------- */

    gl.bindFramebuffer(gl.FRAMEBUFFER, offscreen2.framebuffer)
    gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)

    offscreen2.program.use()

    uniformsForGaussY.int("uDirection", 1)
    uniformsForGaussY.int("uFilterSize", filterSize)
    uniformsForGaussY.float("uSigma", sigma)

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

      offscreen1.useTextureOn(0, offscreen2.program)
      gl.drawElements(gl.TRIANGLES, obj.indices.length, gl.UNSIGNED_SHORT, 0)

      obj.cleanup()
    })

    /* to Offscreen (Gradient) ------------- */

    gl.bindFramebuffer(gl.FRAMEBUFFER, offscreen3.framebuffer)

    offscreen3.program.use()

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

      offscreen2.useTextureOn(0, offscreen3.program)
      gl.drawElements(gl.TRIANGLES, obj.indices.length, gl.UNSIGNED_SHORT, 0)

      obj.cleanup()
    })

    /* to Canvas (Canny Edge Detection) ------------- */

    gl.bindFramebuffer(gl.FRAMEBUFFER, null)
    gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)

    program.use()

    uniformsForCanny.float("uThreshold", threshould)

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

      offscreen3.useTextureOn(0, program)
      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()
}