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 = 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 */ // 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 = 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(skyProgram, "view"); gl.uniformMatrix4fv(sky_view_loc, false, view); let sky_projection_loc = gl.getUniformLocation(skyProgram, "projection"); gl.uniformMatrix4fv(sky_projection_loc, false, projection); let sky_sun_loc = gl.getUniformLocation(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(defaultProgram, "view"); gl.uniformMatrix4fv(view_loc, false, view); let projection_loc = gl.getUniformLocation(defaultProgram, "projection"); gl.uniformMatrix4fv(projection_loc, false, projection); let projectorMatrix_loc = gl.getUniformLocation(defaultProgram, "uProjectorMatrix"); gl.uniformMatrix4fv(projectorMatrix_loc, false, projectedOcean.projectorMatrix); let rangeMatrix_loc = gl.getUniformLocation(defaultProgram, "uRangeMatrix"); gl.uniformMatrix4fv(rangeMatrix_loc, false, projectedOcean.rangeMatrix); let eye_loc = gl.getUniformLocation(defaultProgram, "eyePos"); gl.uniform3fv(eye_loc, camera.pos); let uTime_loc = gl.getUniformLocation(defaultProgram, "uTime"); gl.uniform1f(uTime_loc, timeSpent); // Ocean shader settings let uWaveHeight_loc = gl.getUniformLocation(defaultProgram, "uWaveHeight"); gl.uniform1f(uWaveHeight_loc, waveHeight); let uWaveSpeed_loc = gl.getUniformLocation(defaultProgram, "uWaveSpeed"); gl.uniform1f(uWaveSpeed_loc, waveSpeed); let uFoamIntensity_loc = gl.getUniformLocation(defaultProgram, "uFoamIntensity"); gl.uniform1f(uFoamIntensity_loc, foamIntensity); let uGlitterIntensity_loc = gl.getUniformLocation(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(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 = 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();