442 lines
16 KiB
TypeScript
442 lines
16 KiB
TypeScript
import { vec3, vec4, mat4 } from 'gl-matrix';
|
|
import { Camera } from './Camera';
|
|
import { ProjectedOcean } from './OceanLOD';
|
|
import { Skybox } from './Skybox';
|
|
import { createProgram } from './Shader';
|
|
import * as Config from './constants';
|
|
|
|
var gl: WebGL2RenderingContext;
|
|
var viewportWidth = 0;
|
|
var viewportHeight = 0;
|
|
|
|
/** A camera that always looks at the world origin. Can have an offset and be rotated. */
|
|
// Moved to Camera.ts
|
|
|
|
/** Init OpenGL and gets the viewport/canvas sizes */
|
|
function initGL(canvas: HTMLCanvasElement) {
|
|
// Helper function for canvas resize
|
|
const updateCanvasSize = (canvas: HTMLCanvasElement) => {
|
|
const displayWidth = window.innerWidth;
|
|
const displayHeight = window.innerHeight;
|
|
|
|
if (canvas.width !== displayWidth || canvas.height !== displayHeight) {
|
|
canvas.width = displayWidth;
|
|
canvas.height = displayHeight;
|
|
viewportWidth = displayWidth;
|
|
viewportHeight = displayHeight;
|
|
|
|
if (gl) {
|
|
gl.viewport(0, 0, viewportWidth, viewportHeight);
|
|
}
|
|
}
|
|
};
|
|
|
|
var gltemp;
|
|
try {
|
|
gltemp = canvas.getContext("webgl2");
|
|
if (!gltemp)
|
|
gltemp = canvas.getContext("experimental-webgl2");
|
|
if (gltemp != null) {
|
|
updateCanvasSize(canvas);
|
|
}
|
|
|
|
} catch (e) {
|
|
}
|
|
// Not the best error detection logic.
|
|
// Redirect to http://get.webgl.org in failure case.
|
|
if (gltemp == null) {
|
|
console.error("Unable to initialize WebGL2. Your browser or machine may not support it.");
|
|
return;
|
|
}
|
|
gl = <WebGL2RenderingContext>gltemp;
|
|
//WebGL2 supports floating point textures by default but it does not support filtering them or rendering to them by default. Note: 16bit filtering is included 32bit not
|
|
if (!gl.getExtension('EXT_color_buffer_float')) {
|
|
console.error("32Bit/16Bit single Color render Buffers not available.");
|
|
} //allow 16bit texture as framebuffer target
|
|
|
|
gl.enable(gl.DEPTH_TEST);
|
|
|
|
return updateCanvasSize;
|
|
}
|
|
|
|
/** Update canvas size to fill window */
|
|
// Moved inline below
|
|
|
|
/** Grid for the watersurface */
|
|
// Moved to Grid.ts
|
|
|
|
/** Init Geometry for a Triangle */
|
|
var VBO: WebGLBuffer | null = null;
|
|
function initGeometry() {
|
|
VBO = gl.createBuffer();
|
|
//Vertex data represent fullscreen quad in NDC-Space
|
|
// X, Y, Z, U, V
|
|
let vertexData = [-1.0, -1.0, 0.0, /*BOTTOM LEFT*/ 0.0, 0.0,
|
|
1.0, -1.0, 0.0, /*BOTTOM RIGHT*/ 1.0, 0.0,
|
|
-1.0, 1.0, 0.0, /*TOP LEFT */ 0.0, 1.0,
|
|
1.0, -1.0, 0.0, /*BOTTOM RIGHT */ 1.0, 0.0,
|
|
-1.0, 1.0, 0.0, /*TOP LEFT */ 0.0, 1.0,
|
|
1.0, 1.0, 0.0, /*TOP RIGHT */ 1.0, 1.0
|
|
];
|
|
|
|
gl.bindBuffer(gl.ARRAY_BUFFER, VBO);
|
|
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertexData), gl.STATIC_DRAW);
|
|
}
|
|
|
|
/** Get shader source by HTML-Element<id> */
|
|
// Moved to Shader.ts
|
|
|
|
/** Init all Shaders that are needed */
|
|
var perlinNoiseProgram: WebGLProgram | null;
|
|
var defaultProgram: WebGLProgram | null;
|
|
var textureProgram: WebGLProgram | null;
|
|
var skyProgram: WebGLProgram | null;
|
|
function initShaders() {
|
|
perlinNoiseProgram = createProgram(gl, "ndc-vs", "noise-fs", "Perlin Noise");
|
|
defaultProgram = createProgram(gl, "default-vs", "default-fs", "Default");
|
|
textureProgram = createProgram(gl, "texture-vs", "texture-fs", "Texture");
|
|
skyProgram = createProgram(gl, "sky-vs", "sky-fs", "Sky");
|
|
}
|
|
|
|
/** Init an FBO used for the first render pass / perlin noise */
|
|
var perlinNoiseFBO: WebGLFramebuffer | null = null;
|
|
var textureFBO: WebGLTexture | null = null;
|
|
var perlinNoiseFBOWidth = Config.NOISE_TEXTURE_WIDTH;
|
|
var perlinNoiseFBOHeight = Config.NOISE_TEXTURE_HEIGHT;
|
|
function initFBO() {
|
|
perlinNoiseFBO = gl.createFramebuffer();
|
|
gl.bindFramebuffer(gl.FRAMEBUFFER, perlinNoiseFBO);
|
|
|
|
// Add attachments
|
|
textureFBO = gl.createTexture();
|
|
gl.bindTexture(gl.TEXTURE_2D, textureFBO); //last 3 parameter not intertesting becuase we are not supplying data
|
|
gl.texImage2D(gl.TEXTURE_2D, 0, gl.R16F, perlinNoiseFBOWidth, perlinNoiseFBOHeight, 0, gl.RED, gl.HALF_FLOAT, null);
|
|
|
|
// set the filtering so we don't need mips
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT);
|
|
|
|
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, textureFBO, 0);
|
|
|
|
if (gl.checkFramebufferStatus(gl.FRAMEBUFFER) != gl.FRAMEBUFFER_COMPLETE) {
|
|
console.log("Framebuffer creation failed.");
|
|
}
|
|
|
|
gl.bindFramebuffer(gl.FRAMEBUFFER, null); //Reset to default framebuffer
|
|
}
|
|
|
|
/** Update/Draw function.*/
|
|
/** Framerate measurement variables */
|
|
var timeSpent = 0.0;
|
|
var lastTime = new Date().getTime();
|
|
var counter = 0.0;
|
|
var fps = 0;
|
|
var fpsDisplay: HTMLElement | null = null;
|
|
/** Input states*/
|
|
var mouseXVel = 0;
|
|
var mouseYVel = 0;
|
|
var keysPressed: Set<string> = new Set();
|
|
/** Objects and states*/
|
|
var camera: Camera;
|
|
var projectedOcean: ProjectedOcean;
|
|
var skybox: Skybox;
|
|
var wireframeMode = false;
|
|
/** Camera movement speed */
|
|
var moveSpeed = 0.15;
|
|
var fastMoveSpeed = 0.4;
|
|
/** Ocean shader settings */
|
|
var waveHeight = 1.0;
|
|
var waveSpeed = 1.0;
|
|
var foamIntensity = 1.0;
|
|
var glitterIntensity = 1.0;
|
|
function drawScene() {
|
|
fps++;
|
|
let now = new Date();
|
|
let delta = now.getTime() - lastTime;
|
|
timeSpent += delta;
|
|
|
|
if ((counter += delta) >= Config.FPS_UPDATE_INTERVAL) {
|
|
counter = 0;
|
|
if (fpsDisplay) {
|
|
fpsDisplay.textContent = `FPS: ${fps}`;
|
|
}
|
|
fps = 0;
|
|
}
|
|
lastTime = now.getTime();
|
|
|
|
// Sun direction (matches the one in ocean shader)
|
|
const sunDirection = vec3.fromValues(0.3, 0.5, 0.8);
|
|
vec3.normalize(sunDirection, sunDirection);
|
|
|
|
//--- Render pass -> Skybox first (no depth write) ---
|
|
{
|
|
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
|
gl.viewport(0, 0, viewportWidth, viewportHeight);
|
|
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
|
|
|
|
var projection = mat4.create();
|
|
mat4.perspective(projection, Config.FOV, viewportWidth / viewportHeight, Config.NEAR_PLANE, Config.FAR_PLANE);
|
|
|
|
// Handle FPS camera movement
|
|
handleCameraMovement();
|
|
|
|
// Apply mouse rotation
|
|
if (mouseXVel !== 0 || mouseYVel !== 0) {
|
|
camera.rotate(mouseXVel, mouseYVel);
|
|
mouseXVel = 0;
|
|
mouseYVel = 0;
|
|
}
|
|
|
|
var view = camera.getViewMatrix();
|
|
|
|
// Draw skybox first with depth test disabled (always behind everything)
|
|
gl.depthMask(false);
|
|
gl.disable(gl.DEPTH_TEST);
|
|
gl.useProgram(skyProgram);
|
|
|
|
let sky_view_loc = gl.getUniformLocation(<WebGLProgram>skyProgram, "view");
|
|
gl.uniformMatrix4fv(sky_view_loc, false, view);
|
|
let sky_projection_loc = gl.getUniformLocation(<WebGLProgram>skyProgram, "projection");
|
|
gl.uniformMatrix4fv(sky_projection_loc, false, projection);
|
|
let sky_sun_loc = gl.getUniformLocation(<WebGLProgram>skyProgram, "uSunDirection");
|
|
gl.uniform3fv(sky_sun_loc, sunDirection);
|
|
|
|
skybox.draw(gl);
|
|
gl.enable(gl.DEPTH_TEST);
|
|
gl.depthMask(true);
|
|
|
|
// Update projected ocean's projector matrices
|
|
projectedOcean.updateProjector(camera.pos, camera.forward, view, projection);
|
|
|
|
gl.useProgram(defaultProgram);
|
|
let view_loc = gl.getUniformLocation(<WebGLProgram>defaultProgram, "view");
|
|
gl.uniformMatrix4fv(view_loc, false, view);
|
|
let projection_loc = gl.getUniformLocation(<WebGLProgram>defaultProgram, "projection");
|
|
gl.uniformMatrix4fv(projection_loc, false, projection);
|
|
let projectorMatrix_loc = gl.getUniformLocation(<WebGLProgram>defaultProgram, "uProjectorMatrix");
|
|
gl.uniformMatrix4fv(projectorMatrix_loc, false, projectedOcean.projectorMatrix);
|
|
let rangeMatrix_loc = gl.getUniformLocation(<WebGLProgram>defaultProgram, "uRangeMatrix");
|
|
gl.uniformMatrix4fv(rangeMatrix_loc, false, projectedOcean.rangeMatrix);
|
|
let eye_loc = gl.getUniformLocation(<WebGLProgram>defaultProgram, "eyePos");
|
|
gl.uniform3fv(eye_loc, camera.pos);
|
|
let uTime_loc = gl.getUniformLocation(<WebGLProgram>defaultProgram, "uTime");
|
|
gl.uniform1f(uTime_loc, timeSpent);
|
|
|
|
// Ocean shader settings
|
|
let uWaveHeight_loc = gl.getUniformLocation(<WebGLProgram>defaultProgram, "uWaveHeight");
|
|
gl.uniform1f(uWaveHeight_loc, waveHeight);
|
|
let uWaveSpeed_loc = gl.getUniformLocation(<WebGLProgram>defaultProgram, "uWaveSpeed");
|
|
gl.uniform1f(uWaveSpeed_loc, waveSpeed);
|
|
let uFoamIntensity_loc = gl.getUniformLocation(<WebGLProgram>defaultProgram, "uFoamIntensity");
|
|
gl.uniform1f(uFoamIntensity_loc, foamIntensity);
|
|
let uGlitterIntensity_loc = gl.getUniformLocation(<WebGLProgram>defaultProgram, "uGlitterIntensity");
|
|
gl.uniform1f(uGlitterIntensity_loc, glitterIntensity);
|
|
|
|
// Calculate horizon Y in clip space
|
|
// The skybox horizon is where rayDir.z = 0 (horizontal ray from camera)
|
|
// This is a point at infinity in a horizontal direction from the camera
|
|
// We need to find where this projects to in clip space
|
|
|
|
// Get a horizontal direction (camera forward projected onto XY plane)
|
|
const horizonDir = vec3.fromValues(camera.forward[0], camera.forward[1], 0);
|
|
if (vec3.length(horizonDir) > 0.001) {
|
|
vec3.normalize(horizonDir, horizonDir);
|
|
} else {
|
|
vec3.set(horizonDir, 1, 0, 0);
|
|
}
|
|
|
|
// Transform a direction vector (not a point) to clip space
|
|
// For a point at infinity in direction D, its clip space position is:
|
|
// lim(t->inf) ViewProj * (eye + t*D) / w
|
|
// Which equals ViewProj * D (as a vec4 with w=0), then we look at x/w, y/w
|
|
// But since w would be 0 for a direction, we use the view matrix only
|
|
|
|
// The horizon is where view-space Y = 0 for an infinite point
|
|
// In our Z-up system, the horizon is where the ray is horizontal (z=0 in world)
|
|
// Transform a horizontal direction through view matrix
|
|
const horizonDirView = vec4.fromValues(horizonDir[0], horizonDir[1], 0, 0);
|
|
vec4.transformMat4(horizonDirView, horizonDirView, view);
|
|
|
|
// The Y in clip space where this direction points is based on the view-space direction
|
|
// projected through the projection matrix
|
|
// For perspective: clipY/clipW = viewY/(-viewZ) * projectionScaleY
|
|
// For a horizontal ray at infinity, we can compute where it ends up
|
|
|
|
// Simpler approach: transform a point very far away in horizon direction
|
|
const farDist = 1000000.0;
|
|
const horizonPoint = vec4.fromValues(
|
|
camera.pos[0] + horizonDir[0] * farDist,
|
|
camera.pos[1] + horizonDir[1] * farDist,
|
|
camera.pos[2], // Same height as camera - this is the horizon!
|
|
1
|
|
);
|
|
const viewProj = mat4.create();
|
|
mat4.multiply(viewProj, projection, view);
|
|
vec4.transformMat4(horizonPoint, horizonPoint, viewProj);
|
|
const horizonClipY = horizonPoint[3] !== 0 ? horizonPoint[1] / horizonPoint[3] : 0;
|
|
|
|
let uHorizonClipY_loc = gl.getUniformLocation(<WebGLProgram>defaultProgram, "uHorizonClipY");
|
|
gl.uniform1f(uHorizonClipY_loc, horizonClipY);
|
|
|
|
// Enable backface culling so ocean isn't visible from below
|
|
gl.enable(gl.CULL_FACE);
|
|
gl.cullFace(gl.BACK);
|
|
gl.frontFace(gl.CCW);
|
|
|
|
projectedOcean.draw(gl, wireframeMode);
|
|
|
|
gl.disable(gl.CULL_FACE);
|
|
}
|
|
requestAnimationFrame(drawScene);
|
|
}
|
|
|
|
/** Handle FPS camera movement */
|
|
function handleCameraMovement() {
|
|
const speed = keysPressed.has('Shift') ? fastMoveSpeed : moveSpeed;
|
|
|
|
// WASD for horizontal movement
|
|
if (keysPressed.has('w') || keysPressed.has('W')) {
|
|
camera.moveForward(speed);
|
|
}
|
|
if (keysPressed.has('s') || keysPressed.has('S')) {
|
|
camera.moveForward(-speed);
|
|
}
|
|
if (keysPressed.has('a') || keysPressed.has('A')) {
|
|
camera.moveRight(-speed);
|
|
}
|
|
if (keysPressed.has('d') || keysPressed.has('D')) {
|
|
camera.moveRight(speed);
|
|
}
|
|
|
|
// Q/E for vertical movement
|
|
if (keysPressed.has('q') || keysPressed.has('Q')) {
|
|
camera.moveUp(-speed);
|
|
}
|
|
if (keysPressed.has('e') || keysPressed.has('E')) {
|
|
camera.moveUp(speed);
|
|
}
|
|
|
|
// Space to go up, Ctrl to go down
|
|
if (keysPressed.has(' ')) {
|
|
camera.moveUp(speed);
|
|
}
|
|
if (keysPressed.has('Control')) {
|
|
camera.moveUp(-speed);
|
|
}
|
|
}
|
|
|
|
function main() {
|
|
const canvas: HTMLCanvasElement = <HTMLCanvasElement>document.getElementById("window");
|
|
fpsDisplay = document.getElementById("fps-counter");
|
|
|
|
const updateCanvasSize = initGL(canvas);
|
|
if (!updateCanvasSize) {
|
|
console.error("Failed to initialize WebGL");
|
|
return;
|
|
}
|
|
|
|
var drag = false;
|
|
var previousPosX: number | null;
|
|
var previousPosY: number | null;
|
|
canvas.addEventListener('mousedown', function (evt) {
|
|
drag = true;
|
|
}, false);
|
|
canvas.addEventListener('mousemove', function (evt) {
|
|
if (drag) {
|
|
if (previousPosX == null || previousPosY == null) {
|
|
previousPosX = evt.x;
|
|
previousPosY = evt.y;
|
|
}
|
|
var mousePosX = evt.x;
|
|
var mousePosY = evt.y;
|
|
mouseXVel = (mousePosX - previousPosX);
|
|
mouseYVel = (mousePosY - previousPosY);
|
|
previousPosX = mousePosX;
|
|
previousPosY = mousePosY;
|
|
}
|
|
}, false);
|
|
var deactivateMouseMovement = function () {
|
|
previousPosX = null;
|
|
previousPosY = null;
|
|
mouseXVel = 0.0;
|
|
mouseYVel = 0.0;
|
|
drag = false;
|
|
}
|
|
canvas.addEventListener('mouseup', deactivateMouseMovement, false);
|
|
canvas.addEventListener('mouseleave', deactivateMouseMovement, false);
|
|
|
|
// Keyboard controls
|
|
window.addEventListener('keydown', (evt) => {
|
|
keysPressed.add(evt.key);
|
|
|
|
// Reset camera on 'R' key
|
|
if (evt.key === 'r' || evt.key === 'R') {
|
|
camera = new Camera(); // Reset to initial position
|
|
}
|
|
|
|
// Prevent default for space to avoid page scroll
|
|
if (evt.key === ' ') {
|
|
evt.preventDefault();
|
|
}
|
|
});
|
|
|
|
window.addEventListener('keyup', (evt) => {
|
|
keysPressed.delete(evt.key);
|
|
});
|
|
|
|
// Window resize handler
|
|
window.addEventListener('resize', () => {
|
|
updateCanvasSize(canvas);
|
|
});
|
|
|
|
// Wireframe toggle handler
|
|
window.addEventListener('toggleWireframe', () => {
|
|
wireframeMode = !wireframeMode;
|
|
const wireframeBtn = document.getElementById('wireframe-toggle');
|
|
if (wireframeBtn) {
|
|
wireframeBtn.textContent = `Wireframe: ${wireframeMode ? 'ON' : 'OFF'}`;
|
|
}
|
|
console.log(`Wireframe mode: ${wireframeMode ? 'ON' : 'OFF'}`);
|
|
});
|
|
|
|
// Ocean settings sliders
|
|
window.addEventListener('waveHeightChange', ((evt: CustomEvent) => {
|
|
waveHeight = evt.detail;
|
|
}) as EventListener);
|
|
|
|
window.addEventListener('waveSpeedChange', ((evt: CustomEvent) => {
|
|
waveSpeed = evt.detail;
|
|
}) as EventListener);
|
|
|
|
window.addEventListener('foamIntensityChange', ((evt: CustomEvent) => {
|
|
foamIntensity = evt.detail;
|
|
}) as EventListener);
|
|
|
|
window.addEventListener('glitterIntensityChange', ((evt: CustomEvent) => {
|
|
glitterIntensity = evt.detail;
|
|
}) as EventListener);
|
|
|
|
initShaders();
|
|
initGeometry();
|
|
initFBO();
|
|
|
|
projectedOcean = new ProjectedOcean();
|
|
projectedOcean.initVAO(gl);
|
|
console.log(`Projected ocean initialized with ${projectedOcean.getIndexCount()} indices`);
|
|
|
|
skybox = new Skybox();
|
|
skybox.initVAO(gl);
|
|
console.log('Skybox initialized');
|
|
|
|
camera = new Camera();
|
|
//Check if any errors apeared during init.
|
|
if (gl.getError() != gl.NO_ERROR) {
|
|
console.log("OpenGL Error!: ");
|
|
}
|
|
|
|
drawScene();
|
|
}
|
|
|
|
main(); |