343 lines
11 KiB
TypeScript
343 lines
11 KiB
TypeScript
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 };
|