index.vert

#version 300 es

const int lightsCount = 4;

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

// Matrix
uniform mat4 uModelViewMatrix;
uniform mat4 uProjectionMatrix;
uniform mat4 uNormalMatrix;
// Light
uniform vec3 uLightPosition[lightsCount];
// Material
uniform vec4 uMaterialDiffuse;
// other
uniform bool uWireframe;

out vec3 vNormal;
out vec3 vLightRay[lightsCount];

void main() {
  vec4 vertex = uModelViewMatrix * vec4(aVertexPosition, 1.0);
  
  vNormal = vec3(uNormalMatrix * vec4(aVertexNormal, 1.0));
  
  for (int i = 0; i < lightsCount; i++) {
    vec4 lightPosition = uModelViewMatrix * vec4(uLightPosition[i], 1.0);
    vLightRay[i] = vertex.xyz - lightPosition.xyz;
  }
  
  gl_Position = uProjectionMatrix * vertex;
}

index.frag

#version 300 es

precision mediump float;

const int lightsCount = 4;

in vec3 vNormal;
in vec3 vLightRay[lightsCount];

// Light
uniform vec4 uLightAmbient;
uniform vec4 uLightDiffuse[lightsCount];
// Material
uniform vec4 uMaterialAmbient;
uniform vec4 uMaterialDiffuse;
// frag
uniform bool uLightSource;
uniform bool uWireframe;
// other
uniform float uCutOff;

out vec4 fragColor;

void main() {
  if (uWireframe || uLightSource) {
    fragColor = uMaterialDiffuse;
  } else {
    vec4 Ia = uLightAmbient * uMaterialAmbient;
    vec4 baseColor = vec4(vec3(0.0), 1.0);
    
    vec3 N = normalize(vNormal);
    vec3 L = vec3(0.0);
    float lambertTerm = 0.0;
    
    for (int i = 0; i < lightsCount; i++) {
      L = normalize(vLightRay[i]);
      lambertTerm = dot(N, -L);
      
      if (lambertTerm > uCutOff) {
        baseColor += uLightDiffuse[i] * uMaterialDiffuse * lambertTerm;
      }
    }
    
    baseColor += Ia;
    fragColor = vec4(vec3(baseColor), 1.0);
  }
}

render.ts

import type { RawVector3, RawVector4 } from "@/lib/math/raw-vector"
import { Space } from "@/lib/canvas/index"
import { Program } from "@/lib/webgl/program"
import { Scene } from "@/lib/webgl/scene"
import { Transforms } from "@/lib/webgl/transforms"
import { Matrix4 } from "@/lib/math/matrix"
import { Clock } from "@/lib/event/clock"
import { ControlUi } from "@/lib/gui/control-ui"
import { Floor } from "@/lib/shape/floor"
import { UniformLoader } from "@/lib/webgl/uniform-loader"
import { AngleCamera } from "@/lib/camera/angle-camera"
import { AngleCameraController } from "@/lib/control/angle-camera-controller"
import { LightGroup, LightItem } from "@/lib/light/light-group"

import vertexSource from "./index.vert?raw"
import fragmentSource from "./index.frag?raw"

import sphereModel from "@/lib/model/sphere3.json" assert { type: "json" }
import wallModel from "@/lib/model/wall.json" assert { type: "json" }

interface LightData {
  id: string
  name: string
  position: RawVector3
  diffuse: RawVector4
}

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: AngleCamera
  let transforms: Transforms
  let clock: Clock
  let lights: LightGroup

  const uniforms = new UniformLoader(gl, ["uWireframe", "uLightSource", "uCutOff"])
  const lightsData: LightData[] = [
    { id: "redLight", name: "Red Light", position: [0, 7, 3], diffuse: [1, 0, 0, 1] },
    { id: "greenLight", name: "Green Light", position: [2.5, 3, 3], diffuse: [0, 1, 0, 1] },
    { id: "blueLight", name: "Blue Light", position: [-2.5, 3, 3], diffuse: [0, 0, 1, 1] },
    { id: "whiteLight", name: "White Light", position: [0, 10, 2], diffuse: [1, 1, 1, 1] }
  ]

  let lightCutOff = 0.5

  const initGuiControls = () => {
    const ui = new ControlUi()
    ui.select("Mode", "ORBIT", ["TRACK", "ORBIT"], (mode) => {
      camera.goHome()
      camera.mode = mode
    })
    lightsData.forEach((light) => {
      ui.xyz(`${light.name} Position`, light.position, -15, 15, 0.1, ({ idx, value }) => {
        const target = lights.get(light.id)
        if (!target) return
        const pos = target.position
        if (!pos) return
        pos[idx] = value
        target.position = pos
        lights.updateUniforms()
      })
    })
    ui.number("Light Cone Cut Off", lightCutOff, 0, 1, 0.01, (v) => (lightCutOff = v))
    ui.action("Go Home", () => {
      camera.goHome()
      camera.mode = "ORBIT"
    })
  }

  const onResize = () => {
    space.fitHorizontal()
    render()
  }

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

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

    gl.enable(gl.BLEND)
    gl.blendEquation(gl.FUNC_ADD)

    gl.clearColor(0.9, 0.9, 0.9, 1)
    gl.clearDepth(1)

    program = new Program(gl, vertexSource, fragmentSource)
    scene = new Scene(gl, program)

    uniforms.init(program)

    lights = new LightGroup(gl, program)

    lightsData.forEach((data) => {
      const light = new LightItem(data.id)
      light.position = data.position
      light.diffuse = data.diffuse
      lights.add(light)
    })

    lights.useMaterial = ["ambient", "diffuse"]
    lights.ambient = [1, 1, 1, 1]
    lights.initUniforms()

    camera = new AngleCamera("ORBIT")
    camera.goHome([0, 5, 30])
    camera.focus = [0, 0, 0]
    camera.azimuth = 0
    camera.elevation = -3
    camera.update()

    new AngleCameraController(canvas, camera)

    transforms = new Transforms(gl, program, camera, canvas)
    clock = new Clock()

    space.onResize = onResize
  }

  const registerGeometry = () => {
    const floor = new Floor(80, 2)

    scene.add({ alias: "floor", ...floor.model, diffuse: [1, 1, 1, 1] })
    scene.add({ alias: "wall", ...wallModel })

    lightsData.forEach((light) => {
      scene.add({ alias: light.id, ...sphereModel, diffuse: light.diffuse })
    })
  }

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

    uniforms.float("uCutOff", lightCutOff)

    scene.traverseDraw((obj) => {
      obj.material?.setUniforms()
      uniforms.boolean("uWireframe", !!obj.wireframe)

      transforms.ModelView = camera.View

      const light = obj.alias ? lights.get(obj.alias) : null
      if (light) {
        const position = light.position ?? [0, 0, 0]
        const matModel = Matrix4.identity().translate(...position)
        transforms.Model = matModel
        uniforms.boolean("uLightSource", true)
      } else {
        uniforms.boolean("uLightSource", false)
      }

      transforms.setMatrixUniforms()

      obj.bind()

      const mode = obj.wireframe ? gl.LINES : gl.TRIANGLES
      gl.drawElements(mode, obj.indices.length, gl.UNSIGNED_SHORT, 0)

      obj.cleanup()
    })
  }

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

    initGuiControls()
  }

  init()
}