voronoi.vert

#version 300 es

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

out vec4 vColor;

void main() {
  vec2 offset = aPoint * 2.0 - 1.0;
  gl_Position = vec4(aVertexPosition.xy + offset, aVertexPosition.z, 1.0);
  vColor = vec4(aPoint, 1.0, 1.0);
}

voronoi.frag

#version 300 es

precision highp float;

in vec4 vColor;
out vec4 fragColor;

void main() {
  fragColor = vColor;
}

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

index.frag

#version 300 es

precision highp float;

uniform sampler2D uOriginal;
uniform sampler2D uVoronoi;

uniform float uMixingRatio;

in vec2 vTextureCoords;

out vec4 fragColor;

void main() {
  vec2 uv = vec2(vTextureCoords.x, 1.0 - vTextureCoords.y);

  vec3 original = texture(uOriginal, uv).rgb;
  vec2 center = texture(uVoronoi, uv).xy;
  vec3 voronoi = texture(uOriginal, center).rgb;

  fragColor = vec4(mix(original, voronoi, uMixingRatio), 1.0);
}

render.ts

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

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

import voronoiVertSrc from "./voronoi.vert?raw"
import voronoiFragSrc from "./voronoi.frag"

import imageGeometry from "@/assets/original/fantasy-unicorn.jpg"
import imageAutumnLeaves from "@/assets/original/autumn-leaves_00037.jpg"
import imageWater from "@/assets/original/trii.jpg"
import imageGoldFish from "@/assets/original/fireworks_00018.jpg"

const count = 2000
const resolution = 64

const generateConeVertex = (canvas: HTMLCanvasElement) => {
  const w = canvas.width
  const h = canvas.height
  const a = new Vector2(w, h).normalize()

  let cone = [0, 0, -0.95]

  for (let i = 0; i < resolution; i++) {
    const v = (i / (resolution - 1)) * Math.PI * 2
    cone.push(Math.cos(v) * a.y * 2)
    cone.push(Math.sin(v) * a.x * 2)
    cone.push(1.0)
  }

  return cone
}

const generatePoints = (count: number) => {
  const points = []

  for (let i = 0; i < count; i++) {
    points.push(Math.random(), Math.random())
  }

  return points
}

const sketch: FilterSketchFn = ({ gl, fitImage, canvas }) => {
  const uniforms = new Uniforms(gl, ["uMixingRatio"])
  let uMixingRatio = 0.5

  const images = [
    { name: "花火", src: imageGoldFish },
    { name: "ユニコーン", src: imageGeometry },
    { name: "紅葉", src: imageAutumnLeaves },
    { name: "鳥居", src: imageWater }
  ]
  const imageNames = images.map((obj) => obj.name)
  const textures = images.map((img) => new ImageTexture(gl, img.src))
  let activeImage = 2

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

  const renderer = new OffscreenRenderer(gl, canvas, voronoiVertSrc, voronoiFragSrc, { texUnitStart: 1 })

  const program = new Program(gl)
  program.attach(mainVertSrc, mainFragSrc)

  uniforms.init(program.glProgram)

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

  gl.enable(gl.DEPTH_TEST)
  gl.enable(gl.BLEND)
  gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)

  return {
    resize: [renderer.resize],

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

        cone.registAttrib("vertice", {
          location: 0,
          components: 3,
          buffer: new Float32Array(generateConeVertex(canvas)),
          divisor: 0
        })
        cone.registAttrib("offset", {
          location: 1,
          components: 2,
          buffer: new Float32Array(generatePoints(count)),
          divisor: 1
        })
        cone.setup()
      }
    ],

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

      cone.bind()
      cone.draw({ primitive: "TRIANGLE_FAN", instanceCount: count })

      renderer.switchToCanvas(program)
      gl.viewport(0, 0, gl.canvas.width, gl.canvas.height)
      gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)

      plane.bind()
      uniforms.float("uMixingRatio", uMixingRatio)
      textures[activeImage].activate(program.glProgram, "uOriginal")
      renderer.useAsTexture("uVoronoi", program.glProgram)
      plane.draw({ primitive: "TRIANGLES" })
    },

    control(ui) {
      ui.select("Image", images[activeImage].name, imageNames, (name) => {
        const idx = imageNames.indexOf(name)
        if (idx < 0) return
        activeImage = idx
        fitImage(textures[activeImage].img)
      })
      ui.number("ボロノイの透明度", uMixingRatio, 0.0, 1.0, 0.01, (v) => {
        uMixingRatio = v
      })
    }
  }
}

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