import { Shader } from "./Shader"; import { mat4 } from "gl-matrix"; import { Cube } from "./cube"; import vertexSource from "./shaders/vertices.glsl"; import fragmentSource from "./shaders/fragments.glsl"; const ROTATION_SPEED = 0.0; const BACKGROUND_COLOR = [0.0588235294118, 0.0588235294118, 0.0588235294118]; class RendererError extends Error {} class Renderer { private canvas: HTMLCanvasElement; private overlay: HTMLSpanElement; private analyser: AnalyserNode; private analyserData: Uint8Array; private lastFrameTime: number; private dTime: number; private nextAnimationFrame?: number; private rotation: number; private buffers: { indices?: WebGLBuffer; positions?: WebGLBuffer; normals?: WebGLBuffer; fft?: WebGLBuffer; velocitiesRead?: WebGLBuffer; velocitiesWrite?: WebGLBuffer; }; constructor( analyser: AnalyserNode, canvas: HTMLCanvasElement, overlay: HTMLSpanElement ) { this.canvas = canvas; this.overlay = overlay; this.analyser = analyser; this.analyserData = new Uint8Array(analyser.frequencyBinCount); this.lastFrameTime = 0; this.dTime = 0; this.rotation = 0; this.buffers = {}; } resizeAndDraw( gl: WebGL2RenderingContext, shader: Shader, observerData: ResizeObserverEntry | null ) { if (this.canvas.parentElement === null) { throw new Error("renderer has been removed from dom"); } if (this.nextAnimationFrame) { cancelAnimationFrame(this.nextAnimationFrame); } // Note: For this to work, it's *incredibly important* for the // canvas to be overflowable by its parent, and its parent to // have `overflow: hidden` set. If using a flexbox, this means // that the canvas has to be `position: absolute`. let width: number; let height: number; if (observerData !== null && observerData.devicePixelContentBoxSize) { width = observerData.devicePixelContentBoxSize[0].inlineSize; height = observerData.devicePixelContentBoxSize[0].blockSize; } else { // Fallback; the above API is even newer than // ResizeObserver, and by setting the observerData to null // we can manually resize at least once without going // through the API. if (this.canvas.parentElement === null) { throw new Error("canvas parent disappeared"); } // Note: This *requires* `box-sizing: border-box` ({ width, height } = this.canvas.parentElement.getBoundingClientRect()); } this.canvas.width = width; this.canvas.height = height; gl.viewport(0, 0, this.canvas.clientWidth, this.canvas.clientHeight); this.updateProjection(gl, shader); // ResizeObserver will call when we should draw, so do our own // time calculation and draw the scene. this.updateTime(performance.now()); this.drawScene(gl, shader); } updateTime(time: number) { this.dTime = time - this.lastFrameTime; this.lastFrameTime = time; } initializeScene() { if (this.canvas.parentElement === null) { throw new Error("canvas was not added to page"); } const gl = this.canvas.getContext("webgl2"); if (gl === null) { throw new RendererError("WebGL (2) is unsupported on this browser"); } const shader = Shader.builder(gl) .addShader(vertexSource, gl.VERTEX_SHADER) .addShader(fragmentSource, gl.FRAGMENT_SHADER) .addAttribute("aVertexPosition") .addAttribute("aVertexNormal") .addAttribute("aHeight") .addUniforms("uProjectionMatrix") .addUniforms("uModelViewMatrix") .addUniforms("uNormalMatrix") .build(); this.initGL(gl, shader); this.updateProjection(gl, shader); this.initBuffers(gl); try { const observer = new ResizeObserver((elements) => { // We only observe one element const element = elements[0]; this.resizeAndDraw(gl, shader, element); }); observer.observe(this.canvas.parentElement); } catch (error) { // If the browser does not support ResizeObserver, we // simply don't resize. Resizing is hard enough, just use // a modern browser. if (error instanceof ReferenceError) { console.warn( "Browser does not support `ResizeObserver`. Canvas resizing will be disabled." ); } else throw error; } this.resizeAndDraw(gl, shader, null); } updateProjection(gl: WebGLRenderingContext, shader: Shader) { const projectionMatrix = mat4.create(); mat4.perspective( projectionMatrix, (45 * Math.PI) / 180, gl.canvas.clientWidth / gl.canvas.clientHeight, 0.1, 100.0 ); gl.uniformMatrix4fv( shader.getUniform("uProjectionMatrix"), false, projectionMatrix ); } initBuffers(gl: WebGLRenderingContext) { // Scale down the unit cube before we use it Cube.vertices = Cube.vertices.map( (num: number) => num / this.analyser.frequencyBinCount ); // Position buffer const positionBuffer = gl.createBuffer(); if (positionBuffer === null) { throw new Error("could not initialize position buffer"); } gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); gl.bufferData(gl.ARRAY_BUFFER, Cube.vertices, gl.STATIC_DRAW); this.buffers.positions = positionBuffer; // Index buffer const indexBuffer = gl.createBuffer(); if (indexBuffer === null) { throw new Error("could not initialize index buffer"); } gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer); gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, Cube.indices, gl.STATIC_DRAW); this.buffers.indices = indexBuffer; // Surface normal buffer const normalBuffer = gl.createBuffer(); if (normalBuffer === null) { throw new Error("could not initialize normal buffer"); } gl.bindBuffer(gl.ARRAY_BUFFER, normalBuffer); gl.bufferData(gl.ARRAY_BUFFER, Cube.normals, gl.STATIC_DRAW); this.buffers.normals = normalBuffer; // fft data buffer const fftBuffer = gl.createBuffer(); if (fftBuffer === null) { throw new Error("could not initialize fft buffer"); } // No need to initialize this buffer here since we will be // updating it as soon as we start rendering anyway. this.buffers.fft = fftBuffer; } initGL(gl: WebGLRenderingContext, shader: Shader) { gl.useProgram(shader.program); gl.clearColor( BACKGROUND_COLOR[0], BACKGROUND_COLOR[1], BACKGROUND_COLOR[2], 1.0 ); gl.clearDepth(1.0); gl.enable(gl.DEPTH_TEST); gl.depthFunc(gl.LESS); } updateMatrices(gl: WebGLRenderingContext, shader: Shader) { this.rotation += (this.dTime / 1000.0) * ROTATION_SPEED; const modelViewMatrix = mat4.create(); mat4.translate(modelViewMatrix, modelViewMatrix, [ 0.0, 0.025, -((this.analyser.frequencyBinCount / gl.canvas.clientWidth) * 3), ]); mat4.rotateX(modelViewMatrix, modelViewMatrix, Math.PI / 16); mat4.rotateY(modelViewMatrix, modelViewMatrix, this.rotation); mat4.translate(modelViewMatrix, modelViewMatrix, [-1.0, 0.0, 0.0]); gl.uniformMatrix4fv( shader.getUniform("uModelViewMatrix"), false, modelViewMatrix ); const normalMatrix = mat4.create(); mat4.invert(normalMatrix, modelViewMatrix); mat4.transpose(normalMatrix, normalMatrix); gl.uniformMatrix4fv( shader.getUniform("uNormalMatrix"), false, normalMatrix ); } updateBuffers(gl: WebGL2RenderingContext, shader: Shader) { if ( this.buffers.indices === undefined || this.buffers.positions === undefined || this.buffers.normals === undefined || this.buffers.fft === undefined ) { throw new Error("failed to create buffers before rendering"); } // Update cube buffers gl.bindBuffer(gl.ARRAY_BUFFER, this.buffers.positions); gl.vertexAttribPointer( shader.getAttribute("aVertexPosition"), 3, gl.FLOAT, false, 0, 0 ); gl.enableVertexAttribArray(shader.getAttribute("aVertexPosition")); gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.buffers.indices); gl.bindBuffer(gl.ARRAY_BUFFER, this.buffers.normals); gl.vertexAttribPointer( shader.getAttribute("aVertexNormal"), 3, gl.FLOAT, false, 0, 0 ); gl.enableVertexAttribArray(shader.getAttribute("aVertexNormal")); // Update fft this.analyser.getByteFrequencyData(this.analyserData); gl.bindBuffer(gl.ARRAY_BUFFER, this.buffers.fft); gl.bufferData(gl.ARRAY_BUFFER, this.analyserData, gl.STREAM_DRAW); gl.vertexAttribPointer( shader.getAttribute("aHeight"), 1, gl.UNSIGNED_BYTE, false, 0, 0 ); gl.vertexAttribDivisor(shader.getAttribute("aHeight"), 1); gl.enableVertexAttribArray(shader.getAttribute("aHeight")); } drawScene(gl: WebGL2RenderingContext, shader: Shader) { this.updateMatrices(gl, shader); this.updateBuffers(gl, shader); let cpuTime = 0; if (process.env.NODE_ENV === "development") { cpuTime = Math.round(performance.now() - this.lastFrameTime); } gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); gl.drawElementsInstanced( gl.TRIANGLES, 36, gl.UNSIGNED_SHORT, 0, this.analyser.frequencyBinCount ); if (process.env.NODE_ENV === "development") { const gpuTime = Math.round(performance.now() - this.lastFrameTime); const dTime = Math.round(this.dTime); this.overlay.innerText = `${dTime}ms (${cpuTime}ms / ${gpuTime}ms)`; } this.nextAnimationFrame = requestAnimationFrame((time) => { this.updateTime(time); this.drawScene(gl, shader); }); } } export { Renderer, RendererError };