index.vert

#version 300 es

in vec3 aVertexPosition;
in vec3 aVertexNormal;
in vec4 aVertexColor;

uniform mat4 uModelViewMatrix;
uniform mat4 uProjectionMatrix;

uniform vec3 uLightDirection;
uniform vec3 uEyeDirection;
uniform vec4 uAmbientColor;
uniform mat4 uInvModelMatrix;

out vec4 vColor;

void main() {
  // モデルが回転などの座標変換を行なっていても、それと真逆の変換をライトベクトルに適用することで相殺する
  vec3 invLight = normalize(uInvModelMatrix * vec4(uLightDirection, 0.0)).xyz;
  vec3 invEye = normalize(uInvModelMatrix * vec4(uEyeDirection, 0.0)).xyz;
  
  // ライトベクトルと視線ベクトルとのハーフベクトル
  vec3 halfLE = normalize(invLight + invEye);
  
  // ライト係数
  float diffuse = clamp(dot(aVertexNormal, invLight), 0.1, 1.0);
  
  // 面法線ベクトルとの内積を取ることで反射光を計算
  // 反射光は強いハイライトを演出するためのものなので、
  // 内積によって得られた結果をべき乗によって収束させることで、
  // 弱い光をさらに弱く、強い光はそのまま残すという具合に変換させる
  float specular = pow(clamp(dot(aVertexNormal, halfLE), 0.0, 1.0), 50.0);
  
  // 反射光は光の強さを直接表す係数として使うので、環境光と同じように加算処理で色成分に加える
  vec4 light = uAmbientColor * vec4(vec3(diffuse), 1.0) + vec4(vec3(specular), 1.0);
  
  gl_Position = uProjectionMatrix * uModelViewMatrix * vec4(aVertexPosition, 1.0);
  vColor = aVertexColor * light;
}

index.frag

#version 300 es

// 全ての浮動小数点型の変数に高い精度を指定
precision highp float;

in vec4 vColor;

out vec4 fragColor;

void main() {
  fragColor = vColor;
}

filter.vert

#version 300 es

in vec2 aVertexTextureCoords;
in vec3 aVertexPosition;

out vec2 vTextureCoords;

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

filter.frag

#version 300 es

precision highp float;

// NTSC系加重平均法と呼ばれるグレイスケール変換に使われる手法に則った係数
const float monoR = 0.298912;
const float monoG = 0.586611;
const float monoB = 0.114478;
const vec3 monochromeScale = vec3(monoR, monoG, monoB);

uniform sampler2D uTexture0;
uniform float uKernelX[9];
uniform float uKernelY[9];
uniform bool uUseFilter;
uniform bool uMonoChrome;

in vec2 vTextureCoords;

out vec4 fragColor;

void main() {
  vec2 offset[9];
  offset[0] = vec2(-1.0, -1.0);
  offset[1] = vec2( 0.0, -1.0);
  offset[2] = vec2( 1.0, -1.0);
  offset[3] = vec2(-1.0, 0.0);
  offset[4] = vec2( 0.0, 0.0);
  offset[5] = vec2( 1.0, 0.0);
  offset[6] = vec2(-1.0, 1.0);
  offset[7] = vec2( 0.0, 1.0);
  offset[8] = vec2( 1.0, 1.0);
  
  ivec2 textureSize = textureSize(uTexture0, 0);
  vec2 texelSize = 1.0 / vec2(float(textureSize.x), float(textureSize.y));
  
  vec2 center = gl_FragCoord.xy;
  
  vec3 horizonColor = vec3(0.0);
  vec3 verticalColor = vec3(0.0);
  
  vec4 finalColor = vec4(0.0);
  
  if (uUseFilter) {
    for (int i = 0; i < 9; i++) {
      vec2 offsetX = (center + offset[i]) * texelSize.x;
      vec2 offsetY = (center + offset[i]) * texelSize.y;
      horizonColor += texture(uTexture0, offsetX).rgb * uKernelX[i];
      verticalColor += texture(uTexture0, offsetY).rgb * uKernelY[i];
    }
    finalColor = vec4(sqrt(horizonColor * horizonColor + verticalColor * verticalColor), 1.0);
  } else {
    finalColor = texture(uTexture0, vTextureCoords);
  }
  
  if (uMonoChrome) {
    float grayColor = dot(finalColor.rgb, monochromeScale);
    finalColor = vec4(vec3(grayColor), 1.0);
  }
  
  fragColor = finalColor;
}

render.ts

import { Space } from "@/lib/canvas/index"
import { Program } from "@/lib/webgl/program"
import { Scene } from "@/lib/webgl/scene"
import { Camera } from "@/lib/webgl/camera"
import { Transforms } from "@/lib/webgl/transforms"
import { Matrix4 } from "@/lib/math/matrix"
import { torus } from "@/lib/shape/torus"
import { Vector3 } from "@/lib/math/vector"
import { Clock } from "@/lib/event/clock"
import { Light } from "@/lib/webgl/light"
import { hsvaToRgba } from "@/lib/shape/color"
import { Frame } from "@/lib/webgl/frame"
import { ControlUi } from "@/lib/gui/control-ui"
import { UniformLoader } from "@/lib/webgl/uniform-loader"

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

import filterVertSrc from "./filter.vert?raw"
import filterFragSrc from "./filter.frag?raw"

type FilterType = "Prewitt" | "Sobel" | "Roberts"

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 camera: Camera
  let transforms: Transforms
  let clock: Clock
  let light: Light
  let offscreen: Frame

  let count = 0
  let count2 = 0

  const uniforms = new UniformLoader(gl, ["uKernelX", "uKernelY", "uUseFilter", "uMonoChrome"])

  const filterTypes: FilterType[] = ["Prewitt", "Sobel", "Roberts"]
  const kernels = {
    Prewitt: {
      x: [-1, 0, 1, -1, 0, 1, -1, 0, 1],
      y: [-1, -1, -1, 0, 0, 0, 1, 1, 1]
    },
    Sobel: {
      x: [-1, 0, 1, -2, 0, 2, -1, 0, 1],
      y: [-1, -2, -1, 0, 0, 0, 1, 2, 1]
    },
    Roberts: {
      x: [0, 0, 0, 0, 1, 0, 0, 0, -1],
      y: [0, 0, 0, 0, 0, 1, 0, -1, 0]
    }
  }

  let filterType: FilterType = "Prewitt"
  let useFilter = true
  let monochrome = false

  const initGuiControls = () => {
    const ui = new ControlUi()
    ui.boolean("Edge detection", true, (isActive) => (useFilter = isActive))
    ui.select<FilterType>("Operator", "Prewitt", filterTypes, (mode) => (filterType = mode))
    ui.boolean("Monochrome", false, (isActive) => (monochrome = isActive))
  }

  const onResize = () => {
    space.fitScreenSquare()
    offscreen.resize()
    render()
  }

  const configure = () => {
    space.fitScreenSquare()

    gl.enable(gl.DEPTH_TEST)
    gl.depthFunc(gl.LEQUAL)

    gl.enable(gl.CULL_FACE)

    program = new Program(gl, mainVertSrc, mainFragSrc, false)

    camera = new Camera()
    camera.fov = 90
    camera.near = 0.1
    camera.far = 100

    scene = new Scene(gl, program)
    offscreen = new Frame(gl, canvas, filterVertSrc, filterFragSrc, 0)
    transforms = new Transforms(gl, program, camera, canvas)
    light = new Light(gl, program)
    clock = new Clock()

    uniforms.init(offscreen.program)

    space.onResize = onResize
  }

  const registerGeometry = () => {
    const torusGeometry = torus(2.0, 1.0, 64, 64, [1.0, 1.0, 1.0, 1.0])
    scene.add(torusGeometry)
  }

  const render = () => {
    gl.viewport(0, 0, gl.canvas.width, gl.canvas.height)

    count++
    count % 2 === 0 && count2++
    const rad = ((count % 360) * Math.PI) / 180

    /* フレームバッファに描き込む -------------------------- */

    // フレームバッファのバインド
    gl.bindFramebuffer(gl.FRAMEBUFFER, offscreen.frameBuffer)

    // フレームバッファの初期化
    const hsv = hsvaToRgba(count2 % 360, 1.0, 1.0, 1.0)
    gl.clearColor(...hsv)
    gl.clearDepth(1.0)
    gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)

    program.use()

    camera.eye = [0.0, 0.0, 0.0]
    camera.position = [0.0, 20.0, 0.0]
    camera.up = [0.0, 0.0, -1.0]
    camera.update()

    const torusCount = 9

    // トーラスをレンダリング
    scene.traverseDraw((obj) => {
      obj.bind()

      for (let i = 0; i < torusCount; i++) {
        const model = Matrix4.identity()
          .rotateY((i * 2 * Math.PI) / torusCount)
          .translate(0.0, 0.0, 10.0)
          .rotateAround(new Vector3(1.0, 1.0, 0.0).normalize(), rad)

        light.ambientColor = hsvaToRgba(i * 40, 1, 1, 1)
        light.direction = [-0.577, 0.577, 0.577]
        light.model = model
        light.eye = camera.position
        light.reflect()

        transforms.Model = model
        transforms.setMatrixUniforms()

        gl.drawElements(gl.TRIANGLES, obj.indices.length, gl.UNSIGNED_SHORT, 0)
      }

      obj.cleanup()
    })

    /* キャンバスに描き込む ------------------------------ */

    // フレームバッファのバインドを解除
    gl.bindFramebuffer(gl.FRAMEBUFFER, null)

    // canvasを初期化
    gl.clearColor(0.0, 0.0, 0.0, 1.0)
    gl.clearDepth(1.0)
    gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)

    offscreen.bind()

    uniforms.fvector1("uKernelX", kernels[filterType].x)
    uniforms.fvector1("uKernelY", kernels[filterType].y)
    uniforms.boolean("uUseFilter", useFilter)
    uniforms.boolean("uMonoChrome", monochrome)

    offscreen.draw()
  }

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

    initGuiControls()
  }

  init()
}