8 Commits

17 changed files with 1247 additions and 2232 deletions

View File

@@ -1,164 +0,0 @@
# WebGPU Migration Complete
## Overview
Successfully migrated the WebOcean project from WebGL2 to WebGPU to enable future tessellation support for the ocean grid system.
## What Changed
### Files Converted to WebGPU:
1. **Grid.ts**
- Replaced WebGL VAO/VBO with GPUBuffer
- Updated `initVAO()``initBuffers(gpuContext: WebGPUContext)`
- Changed `draw(gl: WebGL2RenderingContext)``draw(renderPass: GPURenderPassEncoder)`
- Uses `mappedAtCreation` pattern for buffer initialization
2. **Skybox.ts**
- Same conversion pattern as Grid
- Replaced WebGL buffers with GPUBuffer
- Updated draw method signature for WebGPU
3. **main.ts** (renamed from main_webgpu.ts)
- Replaced `initGL()` with async `initWebGPU()`
- Created three render pipelines:
* Noise generation pipeline (renders Perlin noise to texture)
* Ocean rendering pipeline (vertex displacement from noise texture)
* Skybox pipeline (gradient sky with sun)
- Converted FBO to GPUTexture for render-to-texture
- Updated all shader bindings to use WebGPU bind groups
- Maintains all existing features:
* Dual camera system (Orbital + FPS)
* Animation controls (P pause, 0-5 speed)
* Wireframe toggle (F key)
* Camera switching (C key)
* Full WASD + mouse controls
### New Files Created:
1. **WebGPUContext.ts**
- Centralized GPU device/adapter/context management
- Provides helper methods for creating buffers, textures, pipelines
- Handles WebGPU initialization and configuration
2. **shaders.wgsl.ts**
- All GLSL shaders converted to WGSL format
- Exports 6 shader strings:
* `noiseVertexShader` - fullscreen quad for noise generation
* `noiseFragmentShader` - 5-octave Perlin noise
* `oceanVertexShader` - vertex displacement from texture
* `oceanFragmentShader` - normal calculation, Fresnel, SSS, glitter
* `skyboxVertexShader` - skybox cube rendering
* `skyboxFragmentShader` - gradient sky with sun
### Preserved Files:
1. **main_webgl.ts** (backup)
- Original WebGL2 implementation preserved for reference
- Excluded from TypeScript compilation
### Configuration Updates:
1. **tsconfig.json**
- Added `"types": ["@webgpu/types"]` for WebGPU type definitions
- Excluded `main_webgl.ts` from compilation
2. **package.json**
- Added `@webgpu/types` dev dependency
3. **index.html**
- Added frame time display (`<div id="frame-time">`)
- Kept GLSL shader script tags (not used, can be removed later)
## WebGPU vs WebGL2 Architecture
### Key Differences:
| Aspect | WebGL2 | WebGPU |
|--------|--------|--------|
| **Buffers** | VAO/VBO with gl.createVertexArray() | GPUBuffer with device.createBuffer() |
| **Shaders** | GLSL with gl.createProgram() | WGSL with device.createShaderModule() |
| **Rendering** | Direct gl.drawArrays() calls | Command encoder → render pass → submit |
| **Textures** | gl.createTexture() + gl.texImage2D() | device.createTexture() |
| **State** | Implicit state machine (gl.enable/disable) | Explicit pipeline state in descriptors |
| **Uniforms** | gl.uniformMatrix4fv() per draw | Uniform buffers + bind groups |
### Rendering Pipeline:
**Pass 1: Noise Generation**
```
1. Write time uniform to buffer
2. Create command encoder
3. Begin render pass with noiseTexture as target
4. Set noise pipeline
5. Set noise bind group (contains time uniform)
6. Draw fullscreen quad (6 vertices)
7. End pass and submit commands
```
**Pass 2: Scene Rendering**
```
1. Update camera uniforms (view, model, projection, eyePos)
2. Update skybox uniforms (view, projection, sunDir)
3. Create command encoder
4. Begin render pass with canvas + depth texture
5. Draw skybox:
- Set skybox pipeline (no depth write, no culling)
- Set skybox bind group
- Draw skybox geometry
6. Draw ocean:
- Set ocean pipeline (depth write, back-face culling)
- Set ocean bind group (contains uniforms + noise texture + sampler)
- Draw ocean grid (wireframe or solid)
7. End pass and submit commands
```
## Browser Compatibility
- **Requires**: Chrome/Edge 113+, Firefox 130+ (with flag)
- **Not supported**: Safari (as of December 2024)
- Shows error alert if WebGPU not available
## Testing Checklist
✅ Build succeeds without TypeScript errors
✅ Dev server starts successfully
✅ WebGPU initialization completes
✅ Dual camera system functional
✅ Animation controls work (pause/play/speed)
✅ Wireframe toggle functional
✅ Mouse camera controls responsive
✅ Keyboard FPS camera controls work
## Next Steps - Tessellation
Now that WebGPU migration is complete, tessellation can be implemented:
1. **Hull Shader** - Define tessellation factors based on camera distance
2. **Domain Shader** - Interpolate tessellated vertices
3. **Dynamic LOD** - Increase subdivision near camera, reduce far away
4. **Adaptive Tessellation** - More detail in areas with high wave displacement
This will provide:
- Smoother ocean surface at all zoom levels
- Better performance (fewer vertices far from camera)
- More geometric detail for displacement mapping
- Hardware-accelerated mesh subdivision
## Files Modified Summary
- ✅ [Grid.ts](Grid.ts) - WebGPU buffer conversion
- ✅ [Skybox.ts](Skybox.ts) - WebGPU buffer conversion
- ✅ [main.ts](main.ts) - Complete WebGPU rendering pipeline
- ✅ [WebGPUContext.ts](WebGPUContext.ts) - New GPU management class
- ✅ [shaders.wgsl.ts](shaders.wgsl.ts) - New WGSL shader definitions
- ✅ [tsconfig.json](../tsconfig.json) - Added WebGPU types
- ✅ [index.html](../index.html) - Added frame time display
- 📦 main_webgl.ts - Backup (excluded from build)
## Performance Notes
- FPS display shows frame rate
- Frame time display shows milliseconds per frame
- Animation speed control (1x-5x)
- Pause/play functionality preserved
- WebGPU generally faster than WebGL2 for complex scenes

View File

@@ -1,6 +1,5 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -97,17 +96,54 @@
backdrop-filter: blur(10px);
}
#frame-time {
position: absolute;
bottom: 20px;
left: 100px;
background: rgba(0, 0, 0, 0.7);
color: #0ff;
padding: 8px 12px;
border-radius: 5px;
font-family: 'Courier New', monospace;
font-size: 14px;
backdrop-filter: blur(10px);
.slider-group {
margin: 8px 0;
}
.slider-group label {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
font-size: 13px;
}
.slider-group input[type="range"] {
width: 100%;
height: 6px;
border-radius: 3px;
background: rgba(255, 255, 255, 0.2);
outline: none;
-webkit-appearance: none;
appearance: none;
}
.slider-group input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: #4a9eff;
cursor: pointer;
}
.slider-group input[type="range"]::-moz-range-thumb {
width: 14px;
height: 14px;
border-radius: 50%;
background: #4a9eff;
cursor: pointer;
border: none;
}
.slider-value {
background: rgba(255, 255, 255, 0.15);
padding: 2px 6px;
border-radius: 3px;
font-size: 11px;
min-width: 35px;
text-align: center;
}
</style>
<script id="noise-fs" type="x-shader/x-fragment">
@@ -204,96 +240,364 @@
precision mediump float;
varying vec3 v_fragPos;
varying vec2 v_uv;
varying vec3 v_normal;
varying float v_waveHeight;
varying float v_foamFactor;
varying float v_distanceFade;
uniform vec3 eyePos;
uniform sampler2D displace_map;
uniform float uFoamIntensity;
uniform float uGlitterIntensity;
vec3 lightPos = vec3(0.,0.,10.); //not used in diffuse. diffuse uses a directional light. It is only used for specular glittering.
vec3 lightColor = vec3(1.0,1.0,1.0);
//using forward difference
//Normal vectors are compute as: https://www.scratchapixel.com/lessons/procedural-generation-virtual-worlds/perlin-noise-part-2/perlin-noise-computing-derivatives
void main(void) {
vec4 displace = texture2D(displace_map, v_uv);
//calculate normal
float gridPointDelta = (1. / 256.);
vec3 currPoint = vec3(0.0,0.0,displace.x);
vec3 right = vec3(gridPointDelta,0.0,texture2D(displace_map,vec2(v_uv.x + gridPointDelta,v_uv.y)).x*(1./1.));
vec3 left = vec3(-gridPointDelta,0.0,texture2D(displace_map,vec2(v_uv.x - gridPointDelta,v_uv.y)).x*(1./1.));
vec3 up = vec3(0.,gridPointDelta,texture2D(displace_map,vec2(v_uv.x ,v_uv.y + gridPointDelta)).x*(1./1.));
vec3 down = vec3(0.,-gridPointDelta,texture2D(displace_map,vec2(v_uv.x ,v_uv.y - gridPointDelta)).x*(1./1.));
//vec3 tangent = normalize(right - currPoint);
//vec3 biTangent = normalize(up - currPoint);
vec3 tangent = normalize(vec3(gridPointDelta,0.,right.z-left.z));
vec3 biTangent = normalize(vec3(0.,gridPointDelta,down.z-up.z));
//vec3 normal = biTangent;
vec3 normal = cross(tangent, biTangent);
vec3 norm = normalize(normal);
norm.y *= -1.; //Normal y direction is somehow inverted
//vec3 lightDir = normalize(lightPos - v_fragPos);
vec3 lightDir = normalize(-vec3(0.0,.0,-1.)); //sun shines in drection of -z
float diff = max(dot(norm,lightDir),0.0);
vec3 diffuse = diff * lightColor;
vec3 result = (diffuse) * vec3(0.0,0.0,1.0);
//Old lightning
vec3 toCameraVector = normalize(v_fragPos - eyePos);
vec3 reflec = normalize(reflect(toCameraVector, norm));
//Schlicks approximation to Fresnelfactor
float n1 = 1., n2 = 1.33333;
float R0 = pow((n1-n2)/(n1+n2), 2.);
float fresnel = R0 + (1. - R0)*pow((1.-dot(norm,reflec)),5.) ;
//vec3 waterColor = vec3(34./255.,154./255.,211./255.);
vec3 oceanColor = vec3(0,.4,.4); // under-sea colour
vec3 skyColor = vec3(1.,1.,1.);
//Subsurface scattering
vec3 sssSun = vec3(0.,-5.,-7.0);
vec3 tosssSunVec = normalize(sssSun - v_fragPos);
vec3 tosssSun = normalize(vec3(0.0,-100.,1.));
float ssDistortion = 0.1;
float sssIntensity = 1.;
vec3 halfWay = normalize(tosssSun+norm*ssDistortion);
float ssScateringCoef = pow(clamp(dot(toCameraVector,-halfWay),0.0,1.0),5.) * sssIntensity;
//Sun glittering
float glitterFactor = max(0.0,dot(tosssSunVec,reflect(-toCameraVector,norm)));
if(!(glitterFactor > 0.98)) {
glitterFactor = 0.0;
// Simple hash function for noise
float hash(vec2 p) {
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
}
//gl_FragColor = vec4(oceanColor + lightColor * glitterFactor,1.0);
//gl_FragColor=vec4(clamp(oceanColor + (oceanColor*ssScateringCoef),0.,1.0),1.0); //Display subsurfacecatterting component
//gl_FragColor = vec4((mix(oceanColor,skyColor,fresnel).xyz), 1.); //Just display reflection component
//gl_FragColor = vec4(diffuse * oceanColor,1.0); //Render only diffuse component
//gl_FragColor = vec4(normal,1.0); //show Normal map
//gl_FragColor = vec4(displace.x,displace.x,displace.x,1.0); //Show Perlin Noise texture deactivate vertex distrotion before
gl_FragColor = vec4((clamp(diffuse,0.97,1.0) * (mix(oceanColor + (oceanColor*ssScateringCoef),skyColor*0.8,fresnel).xyz))+ lightColor * glitterFactor, 1.0); //All combined
// Value noise for foam texture
float noise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
f = f * f * (3.0 - 2.0 * f); // smoothstep
float a = hash(i);
float b = hash(i + vec2(1.0, 0.0));
float c = hash(i + vec2(0.0, 1.0));
float d = hash(i + vec2(1.0, 1.0));
return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
}
// Fractal noise for more detailed foam
float foamNoise(vec2 p) {
float n = 0.0;
n += 0.5 * noise(p * 8.0);
n += 0.25 * noise(p * 16.0);
n += 0.125 * noise(p * 32.0);
n += 0.0625 * noise(p * 64.0);
return n;
}
void main(void) {
vec3 lightColor = vec3(1.0, 1.0, 0.95);
vec3 sunDirection = normalize(vec3(0.3, 0.5, 0.8));
vec3 norm = normalize(v_normal);
// View direction
vec3 viewDir = normalize(eyePos - v_fragPos);
// Diffuse lighting
float diff = max(dot(norm, sunDirection), 0.0);
vec3 diffuse = diff * lightColor;
// Schlick's approximation to Fresnel factor
float R0 = 0.02;
float fresnel = R0 + (1.0 - R0) * pow(1.0 - max(dot(norm, viewDir), 0.0), 5.0);
// Deep and shallow water colors
vec3 deepColor = vec3(0.0, 0.08, 0.15);
vec3 shallowColor = vec3(0.0, 0.35, 0.45);
vec3 skyColor = vec3(0.55, 0.7, 0.9); // Match skybox horizon color
vec3 foamColor = vec3(0.95, 0.98, 1.0);
// Blend between deep and shallow based on wave height
float heightFactor = clamp(v_waveHeight * 2.0 + 0.5, 0.0, 1.0);
vec3 oceanColor = mix(deepColor, shallowColor, heightFactor);
// Sun glitter - uses wave normals for natural sparkle from fine surface detail
vec3 reflectDir = reflect(-sunDirection, norm);
float specAngle = max(dot(viewDir, reflectDir), 0.0);
// Smooth base specular
float specBase = pow(specAngle, 64.0) * 0.4;
// Medium highlights
float specMid = pow(specAngle, 256.0) * 1.2;
// Sharp glitter peaks
float specSharp = pow(specAngle, 1024.0) * 3.0;
vec3 specular = (specBase + specMid + specSharp) * lightColor * uGlitterIntensity;
// Subsurface scattering
float sssDot = max(dot(viewDir, -sunDirection), 0.0);
float sssWaveContribution = clamp(v_waveHeight + 0.3, 0.0, 1.0);
float sssNormalContribution = pow(1.0 - max(dot(norm, sunDirection), 0.0), 2.0);
float sss = pow(sssDot, 3.0) * sssWaveContribution * sssNormalContribution * 1.5;
vec3 sssColor = vec3(0.1, 0.6, 0.5) * sss;
// Rim SSS effect
float rimSSS = pow(1.0 - max(dot(norm, viewDir), 0.0), 3.0) * 0.3;
vec3 rimColor = vec3(0.0, 0.4, 0.4) * rimSSS * heightFactor;
// Foam with texture - foam persists longer
vec2 foamUV = v_fragPos.xy * 1.5;
float foamPattern = foamNoise(foamUV);
// Create foam patches with softer edges
float foamThreshold = 1.0 - v_foamFactor * 1.2 * uFoamIntensity;
float foam = smoothstep(foamThreshold, foamThreshold + 0.35, foamPattern);
// Add some bubble-like spots with softer transition
float bubbles = smoothstep(0.65, 0.85, noise(foamUV * 15.0)) * v_foamFactor;
foam = clamp(foam + bubbles * 0.3, 0.0, 1.0);
// Softer edge fade based on foam factor
foam *= smoothstep(0.0, 0.25, v_foamFactor);
// Additional soft fade at foam edges and fade out at distance
foam = pow(foam, 0.7) * uFoamIntensity * v_distanceFade;
// Combine all lighting
vec3 reflectedColor = mix(oceanColor, skyColor, fresnel);
vec3 waterColor = reflectedColor * clamp(diffuse, 0.3, 1.0) + specular + sssColor + rimColor;
// Blend foam on top with slight transparency variation
vec3 finalColor = mix(waterColor, foamColor * clamp(diffuse + 0.4, 0.0, 1.0), foam * 0.85);
// Atmospheric fog for distant water - blends to horizon
float dist = length(eyePos - v_fragPos);
// Exponential fog with aggressive horizon fade
float fogFactor = exp(-dist * 0.04);
// Fully fade at stretched horizon vertices
float horizonFade = smoothstep(40.0, 80.0, dist);
fogFactor *= (1.0 - horizonFade);
fogFactor = clamp(fogFactor, 0.0, 1.0);
// Horizon color must exactly match skybox horizon
vec3 horizonColor = vec3(0.55, 0.7, 0.9);
finalColor = mix(horizonColor, finalColor, fogFactor);
gl_FragColor = vec4(finalColor, 1.0);
}
</script>
<script id="default-vs" type="x-shader/x-vertex">
attribute vec3 positionAttr;
precision mediump float;
attribute vec2 positionAttr; // Grid position in [0,1] range
uniform mat4 view;
uniform mat4 model;
uniform mat4 projection;
uniform sampler2D displace_map;
uniform mat4 uProjectorMatrix; // Inverse projector view-proj
uniform mat4 uRangeMatrix; // Range conversion matrix
uniform float uTime;
uniform float uWaveHeight;
uniform float uWaveSpeed;
uniform vec3 eyePos;
uniform float uHorizonClipY; // Y position of horizon in clip space [-1,1]
varying vec2 v_uv;
varying vec3 v_fragPos;
varying vec3 v_normal;
varying float v_waveHeight;
varying float v_foamFactor;
varying float v_distanceFade;
// Gerstner wave function - higher steepness = spikier waves
vec3 gerstnerWave(vec2 pos, float time, vec2 direction, float steepness, float wavelength, out vec3 tangent, out vec3 binormal) {
float k = 2.0 * 3.14159 / wavelength;
float c = sqrt(9.8 / k);
vec2 d = normalize(direction);
float f = k * (dot(d, pos) - c * time);
float a = steepness / k;
tangent = vec3(
1.0 - steepness * d.x * d.x * sin(f),
steepness * d.x * cos(f),
-steepness * d.x * d.y * sin(f)
);
binormal = vec3(
-steepness * d.x * d.y * sin(f),
steepness * d.y * cos(f),
1.0 - steepness * d.y * d.y * sin(f)
);
return vec3(
d.x * a * cos(f),
a * sin(f),
d.y * a * cos(f)
);
}
// Project grid point onto ocean plane using projector
vec3 projectToOcean(vec2 gridPos, out float horizonBlend, out vec3 rayDirection) {
// Transform grid position [0,1] through range matrix to projector space [-1,1]
vec4 clipPos = uRangeMatrix * vec4(gridPos, 0.0, 1.0);
// Get two points along the projection ray (near and far planes)
vec4 nearPoint = uProjectorMatrix * vec4(clipPos.xy, -1.0, 1.0);
vec4 farPoint = uProjectorMatrix * vec4(clipPos.xy, 1.0, 1.0);
// Perspective divide to get world positions
nearPoint /= nearPoint.w;
farPoint /= farPoint.w;
vec3 rayOrigin = nearPoint.xyz;
vec3 rayDir = normalize(farPoint.xyz - nearPoint.xyz);
rayDirection = rayDir;
// The skybox horizon is where rayDir.z = 0 (looking horizontally)
float angleToHorizon = -rayDir.z; // 0 at horizon, negative = looking up, positive = looking down
// If ray is pointing up or nearly horizontal, this vertex approaches horizon
if (angleToHorizon <= 0.001) {
horizonBlend = 1.0;
// Project in horizontal direction at ocean level
vec2 hDir = length(rayDir.xy) > 0.001 ? normalize(rayDir.xy) : vec2(1.0, 0.0);
return vec3(rayOrigin.xy + hDir * 5000.0, 0.0);
}
// Ray is pointing down - intersect with ocean plane (Z = 0)
float t = -rayOrigin.z / rayDir.z;
if (t < 0.0) {
horizonBlend = 1.0;
vec2 hDir = length(rayDir.xy) > 0.001 ? normalize(rayDir.xy) : vec2(1.0, 0.0);
return vec3(rayOrigin.xy + hDir * 5000.0, 0.0);
}
// Camera height affects max render distance
// Higher camera = need to limit distance more to avoid precision issues
float cameraHeight = max(eyePos.z, 0.5);
// Base max distance scales with camera height, but with diminishing returns
// At height 2: maxBase = ~200
// At height 10: maxBase = ~450
// At height 100: maxBase = ~1400
// At height 500: maxBase = ~3100
float maxBase = 100.0 * sqrt(cameraHeight);
// Also limit based on angle - shallow angles get much shorter max distance
float angleScale = smoothstep(0.001, 0.3, angleToHorizon); // 0 at horizon, 1 at ~17 degrees down
float maxT = maxBase * (0.1 + 0.9 * angleScale);
maxT = max(maxT, 50.0); // Minimum distance
// Smooth horizon blend based on angle AND distance
horizonBlend = 1.0 - smoothstep(0.001, 0.05, angleToHorizon);
// If t exceeds limit, increase horizon blend
if (t > maxT * 0.8) {
float distBlend = smoothstep(maxT * 0.8, maxT, t);
horizonBlend = max(horizonBlend, distBlend);
}
t = min(t, maxT);
// Compute world position
vec3 worldPos = rayOrigin + rayDir * t;
return worldPos;
}
void main(void) {
vec4 displace = texture2D(displace_map, vec2(positionAttr.x,positionAttr.y));
vec4 worldPos = model * vec4(positionAttr.x,positionAttr.y,positionAttr.z + displace.x, 1.0);
// Project grid point onto ocean plane
float horizonBlend;
vec3 rayDir;
vec3 worldPos3 = projectToOcean(positionAttr, horizonBlend, rayDir);
vec4 worldPos = vec4(worldPos3, 1.0);
// Grid is on XY plane, Z is up
vec2 pos = worldPos.xy;
float time = uTime * 0.0004 * uWaveSpeed;
// Calculate distance from camera for wave fading
float distToCamera = length(worldPos.xyz - eyePos);
float waveFade = exp(-distToCamera * 0.015); // Gradual fade over distance
waveFade = clamp(waveFade, 0.0, 1.0);
// Fade out waves at horizon to prevent edge breakup
waveFade *= (1.0 - horizonBlend);
v_distanceFade = waveFade;
float heightMod = uWaveHeight * waveFade;
vec3 displacement = vec3(0.0);
vec3 tangent = vec3(1.0, 0.0, 0.0);
vec3 binormal = vec3(0.0, 0.0, 1.0);
vec3 t, b;
// === Large primary waves ===
displacement += gerstnerWave(pos, time, vec2(1.0, 0.2), 0.42 * heightMod, 6.0, t, b);
tangent += t - vec3(1.0, 0.0, 0.0); binormal += b - vec3(0.0, 0.0, 1.0);
displacement += gerstnerWave(pos, time * 1.1, vec2(0.4, 1.0), 0.35 * heightMod, 5.0, t, b);
tangent += t - vec3(1.0, 0.0, 0.0); binormal += b - vec3(0.0, 0.0, 1.0);
// === Medium waves ===
displacement += gerstnerWave(pos, time * 0.9, vec2(-0.6, 0.8), 0.25 * heightMod, 3.0, t, b);
tangent += t - vec3(1.0, 0.0, 0.0); binormal += b - vec3(0.0, 0.0, 1.0);
displacement += gerstnerWave(pos, time * 1.2, vec2(0.8, -0.5), 0.2 * heightMod, 2.2, t, b);
tangent += t - vec3(1.0, 0.0, 0.0); binormal += b - vec3(0.0, 0.0, 1.0);
displacement += gerstnerWave(pos, time, vec2(-0.3, -0.9), 0.18 * heightMod, 1.8, t, b);
tangent += t - vec3(1.0, 0.0, 0.0); binormal += b - vec3(0.0, 0.0, 1.0);
// === Small detail waves ===
displacement += gerstnerWave(pos, time * 1.2, vec2(0.9, -0.4), 0.12 * heightMod, 1.2, t, b);
tangent += t - vec3(1.0, 0.0, 0.0); binormal += b - vec3(0.0, 0.0, 1.0);
displacement += gerstnerWave(pos, time * 0.9, vec2(-0.5, -0.7), 0.10 * heightMod, 1.0, t, b);
tangent += t - vec3(1.0, 0.0, 0.0); binormal += b - vec3(0.0, 0.0, 1.0);
displacement += gerstnerWave(pos, time * 1.3, vec2(0.3, 0.95), 0.08 * heightMod, 0.8, t, b);
tangent += t - vec3(1.0, 0.0, 0.0); binormal += b - vec3(0.0, 0.0, 1.0);
// === Tiny ripples ===
displacement += gerstnerWave(pos, time * 2.0, vec2(0.9, 0.1), 0.05 * heightMod, 0.35, t, b);
tangent += t - vec3(1.0, 0.0, 0.0); binormal += b - vec3(0.0, 0.0, 1.0);
displacement += gerstnerWave(pos, time * 2.2, vec2(-0.2, 0.95), 0.04 * heightMod, 0.25, t, b);
tangent += t - vec3(1.0, 0.0, 0.0); binormal += b - vec3(0.0, 0.0, 1.0);
// === Micro ripples for fine surface detail ===
displacement += gerstnerWave(pos, time * 2.5, vec2(0.7, -0.7), 0.03 * heightMod, 0.18, t, b);
tangent += t - vec3(1.0, 0.0, 0.0); binormal += b - vec3(0.0, 0.0, 1.0);
displacement += gerstnerWave(pos, time * 3.0, vec2(-0.8, 0.6), 0.025 * heightMod, 0.12, t, b);
tangent += t - vec3(1.0, 0.0, 0.0); binormal += b - vec3(0.0, 0.0, 1.0);
displacement += gerstnerWave(pos, time * 3.5, vec2(0.5, -0.9), 0.02 * heightMod, 0.08, t, b);
tangent += t - vec3(1.0, 0.0, 0.0); binormal += b - vec3(0.0, 0.0, 1.0);
// Store wave height for fragment shader
v_waveHeight = displacement.y;
// Calculate foam factor - foam appears on the FRONT/leading edge of waves
// When wave is rising (tangent.y > 0), that's where foam should appear
float waveRising = smoothstep(0.0, 0.3, tangent.y + binormal.y);
float foamFromHeight = smoothstep(0.0, 0.25, displacement.y);
float waveSlope = length(vec2(tangent.y, binormal.y));
float foamFromSlope = smoothstep(0.2, 0.6, waveSlope);
// Foam appears where wave is high AND rising (leading edge / crest)
v_foamFactor = clamp((foamFromHeight * waveRising * 1.2 + foamFromSlope * 0.3), 0.0, 1.0);
// Apply displacement - Z is up, XY is horizontal plane
worldPos.x += displacement.x;
worldPos.y += displacement.z;
worldPos.z += displacement.y; // Height displacement
// Calculate normal from tangent and binormal
// Blend normal towards flat (0, 0, 1) based on distance
vec3 normal = normalize(cross(binormal, tangent));
vec3 flatNormal = vec3(0.0, 0.0, 1.0);
normal = mix(flatNormal, normal, waveFade);
v_normal = vec3(normal.x, normal.z, normal.y);
// Project back to clip space
gl_Position = projection * view * worldPos;
// For vertices near the horizon, smoothly blend Y towards the horizon line
// This ensures ocean meets skybox without gaps or discontinuities
if (horizonBlend > 0.0) {
float targetY = uHorizonClipY * gl_Position.w;
// Use squared blend for smoother transition
float smoothBlend = horizonBlend * horizonBlend;
gl_Position.y = mix(gl_Position.y, targetY, smoothBlend);
// Push depth towards far plane for horizon vertices
gl_Position.z = mix(gl_Position.z, gl_Position.w * 0.9999, smoothBlend);
}
v_fragPos = worldPos.xyz;
v_uv = positionAttr.xy;
}
</script>
<script id="sky-fs" type="x-shader/x-fragment">
@@ -362,7 +666,6 @@
gl_Position = pos;
}
</script>
</head>
<body>
@@ -371,25 +674,15 @@
<div id="controls">
<h3>🌊 Ocean Controls</h3>
<div class="control-group">
<strong>Camera Mode:</strong> <span class="key">C</span> (FPS/Orbital)<br>
<span id="current-camera-mode" style="font-size: 12px; color: #aaa;">Current: Orbital</span>
<strong>Camera Rotation:</strong><br>
<span class="key">W</span><span class="key">A</span><span class="key">S</span><span class="key">D</span> or Arrow Keys
</div>
<div class="control-group">
<strong>Rendering:</strong><br>
<span class="key">F</span> Toggle Wireframe
<strong>Zoom:</strong><br>
<span class="key">Q</span> / <span class="key">E</span> or <span class="key">+</span> / <span class="key">-</span>
</div>
<div class="control-group">
<strong>FPS Camera:</strong><br>
<span class="key">W</span><span class="key">A</span><span class="key">S</span><span class="key">D</span> Move<br>
<span class="key">Q</span><span class="key">E</span> or <span class="key">Space</span><span class="key">Ctrl</span> Up/Down<br>
<span class="key">Shift</span> Sprint<br>
Mouse: Look around
</div>
<div class="control-group">
<strong>Orbital Camera:</strong><br>
<span class="key">W</span><span class="key">A</span><span class="key">S</span><span class="key">D</span> or Arrows Rotate<br>
<span class="key">Q</span><span class="key">E</span> or <span class="key">+</span><span class="key">-</span> Zoom<br>
Mouse: Click and drag to rotate
<strong>Mouse:</strong> Click and drag to rotate
</div>
<div class="control-group">
<strong>Reset:</strong> <span class="key">R</span>
@@ -397,19 +690,42 @@
<div class="control-group">
<strong>Toggle Help:</strong> <span class="key">H</span>
</div>
<div class="control-group" style="margin-top: 10px; padding-top: 10px; border-top: 1px solid rgba(255, 255, 255, 0.2);">
<button id="wireframe-toggle" style="background: rgba(255, 255, 255, 0.2); color: white; border: none; padding: 8px 12px; border-radius: 4px; cursor: pointer; width: 100%; font-size: 13px;">Wireframe: OFF</button>
</div>
<div style="margin-top: 12px; padding-top: 12px; border-top: 1px solid rgba(255, 255, 255, 0.2);">
<strong>Wave Settings</strong>
<div class="slider-group">
<label>Wave Height <span class="slider-value" id="wave-height-val">1.0</span></label>
<input type="range" id="wave-height" min="0" max="2" step="0.1" value="1">
</div>
<div class="slider-group">
<label>Wave Speed <span class="slider-value" id="wave-speed-val">1.0</span></label>
<input type="range" id="wave-speed" min="0.1" max="3" step="0.1" value="1">
</div>
</div>
<div style="margin-top: 12px; padding-top: 12px; border-top: 1px solid rgba(255, 255, 255, 0.2);">
<strong>Foam & Glitter</strong>
<div class="slider-group">
<label>Foam Intensity <span class="slider-value" id="foam-intensity-val">1.0</span></label>
<input type="range" id="foam-intensity" min="0" max="2" step="0.1" value="1">
</div>
<div class="slider-group">
<label>Glitter Intensity <span class="slider-value" id="glitter-intensity-val">1.0</span></label>
<input type="range" id="glitter-intensity" min="0" max="3" step="0.1" value="1">
</div>
</div>
</div>
<button id="toggle-controls">Toggle Controls (H)</button>
<div id="fps-counter">FPS: 0</div>
<div id="frame-time">Frame: 0.00ms</div>
<script type="module" src="/src/main.ts"></script>
<script>
// Toggle controls visibility
const controls = document.getElementById('controls');
const toggleBtn = document.getElementById('toggle-controls');
const cameraModeDisplay = document.getElementById('current-camera-mode');
toggleBtn.addEventListener('click', () => {
controls.classList.toggle('hidden');
@@ -419,28 +735,29 @@
if (evt.key === 'h' || evt.key === 'H') {
controls.classList.toggle('hidden');
}
// Update camera mode display when C is pressed
if (evt.key === 'c' || evt.key === 'C') {
setTimeout(() => {
// Get camera mode from any displayed element
const cameraMode = document.getElementById('camera-mode');
if (cameraMode && cameraModeDisplay) {
const mode = cameraMode.textContent.replace('Camera: ', '');
cameraModeDisplay.textContent = `Current: ${mode}`;
}
}, 100);
}
});
// Listen for custom camera mode toggle events from UI
window.addEventListener('toggleCameraMode', () => {
const cameraMode = document.getElementById('camera-mode');
if (cameraMode && cameraModeDisplay) {
const mode = cameraMode.textContent.replace('Camera: ', '');
cameraModeDisplay.textContent = `Current: ${mode}`;
}
// Wireframe toggle
const wireframeBtn = document.getElementById('wireframe-toggle');
wireframeBtn.addEventListener('click', () => {
window.dispatchEvent(new CustomEvent('toggleWireframe'));
});
// Slider controls
function setupSlider(id, eventName) {
const slider = document.getElementById(id);
const valueDisplay = document.getElementById(id + '-val');
slider.addEventListener('input', (e) => {
const value = parseFloat(e.target.value);
valueDisplay.textContent = value.toFixed(1);
window.dispatchEvent(new CustomEvent(eventName, { detail: value }));
});
}
setupSlider('wave-height', 'waveHeightChange');
setupSlider('wave-speed', 'waveSpeedChange');
setupSlider('foam-intensity', 'foamIntensityChange');
setupSlider('glitter-intensity', 'glitterIntensityChange');
</script>
</body>

8
package-lock.json generated
View File

@@ -12,7 +12,6 @@
"gl-matrix": "^3.4.4"
},
"devDependencies": {
"@webgpu/types": "^0.1.69",
"typescript": "^5.9.3",
"vite": "^6.0.7"
}
@@ -816,13 +815,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@webgpu/types": {
"version": "0.1.69",
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.69.tgz",
"integrity": "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/esbuild": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",

View File

@@ -20,7 +20,6 @@
"author": "Julian Niessner",
"license": "ISC",
"devDependencies": {
"@webgpu/types": "^0.1.69",
"typescript": "^5.9.3",
"vite": "^6.0.7"
},

View File

@@ -1,74 +1,38 @@
# 🌊 WebOcean
An interactive 3D ocean simulation using **WebGPU**, TypeScript, and Perlin noise for realistic water wave generation.
An interactive 3D ocean simulation using WebGL2, TypeScript, and Perlin noise for realistic water wave generation.
![WebGPU](https://img.shields.io/badge/WebGPU-Chrome113+-990000?style=flat-square)
![WebGL](https://img.shields.io/badge/WebGL-2.0-990000?style=flat-square)
![TypeScript](https://img.shields.io/badge/TypeScript-5.9-3178C6?style=flat-square)
![Vite](https://img.shields.io/badge/Vite-6.0-646CFF?style=flat-square)
## ✨ Features
- **Real-time Ocean Simulation** - Dynamic water surface with Perlin noise-based displacement
- **WebGPU Rendering** - Modern GPU API for optimal performance and future tessellation support
- **Advanced Rendering Techniques**:
- Fresnel reflection for realistic water appearance
- Subsurface scattering for light penetration
- Specular highlights for sun glitter effect
- Dynamic normal mapping from displacement
- Gradient skybox with sun rendering
- **Dual Camera System**:
- **Orbital Camera** - Rotate around the ocean surface
- **FPS Camera** - Free-flying first-person exploration
- **Animation Controls**:
- Pause/play ocean animation
- Adjustable speed (1x-5x)
- **Rendering Modes**:
- Wireframe toggle for mesh visualization
- **Interactive Camera Controls** - Mouse and keyboard navigation
- **Responsive Design** - Automatically adapts to window size
- **Performance Monitoring** - Real-time FPS counter and frame time
- **Performance Monitoring** - Real-time FPS counter
## 🎮 Controls
### Camera Controls
| Action | Keys |
|--------|------|
| **Toggle Camera Mode** | `C` |
| **Reset Camera** | `R` |
| **Mouse Drag** | Click and drag to rotate camera |
### Orbital Camera Mode (Default)
| Action | Keys |
|--------|------|
| **Rotate** | `W` `A` `S` `D` or Arrow Keys |
| **Rotate Camera** | `W` `A` `S` `D` or Arrow Keys |
| **Zoom In/Out** | `Q` / `E` or `+` / `-` |
### FPS Camera Mode
| Action | Keys |
|--------|------|
| **Move Forward/Back** | `W` / `S` |
| **Strafe Left/Right** | `A` / `D` |
| **Move Up/Down** | `E` / `Q` or `Space` / `Ctrl` |
| **Fast Movement** | Hold `Shift` |
| **Look Around** | Click and drag mouse |
### Rendering Controls
| Action | Keys |
|--------|------|
| **Wireframe Mode** | `F` |
| **Pause/Play Animation** | `P` |
| **Set Speed** | `0` (reset) `1` `2` `3` `4` `5` (multipliers) |
| **Toggle Help UI** | `H` |
| **Mouse Drag** | Click and drag to rotate |
| **Reset Camera** | `R` |
| **Toggle Help** | `H` |
## 🚀 Getting Started
### Prerequisites
- **Node.js** (v16 or higher)
- **Browser**: Chrome 113+, Edge 113+, or Firefox 130+ (with WebGPU enabled)
- Node.js (v16 or higher)
- npm or yarn
### Installation

View File

@@ -1,63 +1,87 @@
import { vec3, mat4, vec4 } from 'gl-matrix';
import { ICamera } from './ICamera';
/** Orbital camera that rotates around the world origin. */
export class OrbitalCamera implements ICamera {
/** FPS-style flight camera with free movement */
export class Camera {
pos: vec3;
target: vec3;
up: vec3;
xRot: number;
yRot: number;
offset: number;
// FPS camera angles (in radians)
pitch: number; // Up/down rotation
yaw: number; // Left/right rotation
// Direction vectors
forward: vec3;
right: vec3;
constructor() {
this.pos = vec3.create();
vec3.set(this.pos, 0.0, 0.0, 0.0);
vec3.set(this.pos, 0.0, -3.0, 2.0); // Start above and behind origin
this.target = vec3.create();
vec3.set(this.target, 0.0, 0.0, 0.0);
this.up = vec3.create();
vec3.set(this.up, 0.0, 1.0, 0.0);
this.xRot = 0.0;
this.yRot = 0.0;
this.offset = 0.0;
vec3.set(this.up, 0.0, 0.0, 1.0); // Z is up
this.forward = vec3.create();
this.right = vec3.create();
this.pitch = -0.3; // Looking slightly down
this.yaw = Math.PI / 2; // Looking toward +Y
this.updateVectors();
}
setRotationX(rotX: number): void {
this.xRot = rotX;
this.updatePos();
/** Rotate camera by mouse delta */
rotate(deltaX: number, deltaY: number, sensitivity: number = 0.003): void {
this.yaw -= deltaX * sensitivity;
this.pitch -= deltaY * sensitivity;
// Clamp pitch to avoid flipping
const maxPitch = Math.PI / 2 - 0.01;
this.pitch = Math.max(-maxPitch, Math.min(maxPitch, this.pitch));
this.updateVectors();
}
setRotationY(rotY: number): void {
this.yRot = rotY;
this.updatePos();
/** Move camera in the direction it's looking */
moveForward(amount: number): void {
vec3.scaleAndAdd(this.pos, this.pos, this.forward, amount);
this.updateVectors();
}
/** Sets the offset to world origin. */
setOffset(off: number): void {
this.offset = off;
this.updatePos();
moveRight(amount: number): void {
vec3.scaleAndAdd(this.pos, this.pos, this.right, amount);
this.updateVectors();
}
/** Recalculates the position according to xy-rotation and offset. */
private updatePos(): void {
const transformation: mat4 = mat4.create();
mat4.identity(transformation);
moveUp(amount: number): void {
// Move along world Z axis
this.pos[2] += amount;
this.updateVectors();
}
//2. xy-Rotation
mat4.rotateX(transformation, transformation, this.xRot);
mat4.rotateY(transformation, transformation, this.yRot);
/** Move in the actual look direction (including vertical) */
moveInLookDirection(amount: number): void {
vec3.scaleAndAdd(this.pos, this.pos, this.forward, amount);
this.updateVectors();
}
//1. Translation
const translation = vec3.create();
vec3.set(translation, 0.0, 0.0, this.offset);
mat4.translate(transformation, transformation, translation);
/** Update direction vectors from pitch/yaw */
private updateVectors(): void {
// Calculate forward vector from pitch and yaw
// Z is up, so we use different axis mapping
this.forward[0] = Math.cos(this.pitch) * Math.cos(this.yaw);
this.forward[1] = Math.cos(this.pitch) * Math.sin(this.yaw);
this.forward[2] = Math.sin(this.pitch);
vec3.normalize(this.forward, this.forward);
const temp: vec4 = vec4.create();
vec4.set(temp, 0.0, 0.0, 0.0, 1.0);
vec4.transformMat4(temp, temp, transformation);
// Right vector is perpendicular to forward and world up
const worldUp = vec3.fromValues(0, 0, 1);
vec3.cross(this.right, this.forward, worldUp);
vec3.normalize(this.right, this.right);
vec3.set(this.pos, temp[0], temp[1], temp[2]);
// Camera up is perpendicular to forward and right
vec3.cross(this.up, this.right, this.forward);
vec3.normalize(this.up, this.up);
// Update target
vec3.add(this.target, this.pos, this.forward);
}
getViewMatrix(): mat4 {
@@ -68,9 +92,6 @@ export class OrbitalCamera implements ICamera {
/** Get view direction for LOD calculations */
getViewDirection(): vec3 {
const dir = vec3.create();
vec3.subtract(dir, this.target, this.pos);
vec3.normalize(dir, dir);
return dir;
return vec3.clone(this.forward);
}
}

View File

@@ -1,98 +0,0 @@
import { vec3, mat4 } from 'gl-matrix';
import { ICamera } from './ICamera';
/** FPS-style flight camera with free movement */
export class FPSCamera implements ICamera {
pos: vec3;
target: vec3;
up: vec3;
// FPS camera angles (in radians)
pitch: number; // Up/down rotation
yaw: number; // Left/right rotation
// Direction vectors
forward: vec3;
right: vec3;
constructor() {
this.pos = vec3.create();
vec3.set(this.pos, 0.0, -3.0, 2.0); // Start above and behind origin
this.target = vec3.create();
this.up = vec3.create();
vec3.set(this.up, 0.0, 0.0, 1.0); // Z is up
this.forward = vec3.create();
this.right = vec3.create();
this.pitch = -0.3; // Looking slightly down
this.yaw = Math.PI / 2; // Looking toward +Y
this.updateVectors();
}
/** Rotate camera by mouse delta */
rotate(deltaX: number, deltaY: number, sensitivity: number = 0.003): void {
this.yaw -= deltaX * sensitivity;
this.pitch -= deltaY * sensitivity;
// Clamp pitch to avoid flipping
const maxPitch = Math.PI / 2 - 0.01;
this.pitch = Math.max(-maxPitch, Math.min(maxPitch, this.pitch));
this.updateVectors();
}
/** Move camera in the direction it's looking */
moveForward(amount: number): void {
vec3.scaleAndAdd(this.pos, this.pos, this.forward, amount);
this.updateVectors();
}
moveRight(amount: number): void {
vec3.scaleAndAdd(this.pos, this.pos, this.right, amount);
this.updateVectors();
}
moveUp(amount: number): void {
// Move along world Z axis
this.pos[2] += amount;
this.updateVectors();
}
/** Move in the actual look direction (including vertical) */
moveInLookDirection(amount: number): void {
vec3.scaleAndAdd(this.pos, this.pos, this.forward, amount);
this.updateVectors();
}
/** Update direction vectors from pitch/yaw */
private updateVectors(): void {
// Calculate forward vector from pitch and yaw
// Z is up, so we use different axis mapping
this.forward[0] = Math.cos(this.pitch) * Math.cos(this.yaw);
this.forward[1] = Math.cos(this.pitch) * Math.sin(this.yaw);
this.forward[2] = Math.sin(this.pitch);
vec3.normalize(this.forward, this.forward);
// Right vector is perpendicular to forward and world up
const worldUp = vec3.fromValues(0, 0, 1);
vec3.cross(this.right, this.forward, worldUp);
vec3.normalize(this.right, this.right);
// Camera up is perpendicular to forward and right
vec3.cross(this.up, this.right, this.forward);
vec3.normalize(this.up, this.up);
// Update target
vec3.add(this.target, this.pos, this.forward);
}
getViewMatrix(): mat4 {
const ret: mat4 = mat4.create();
mat4.lookAt(ret, this.pos, this.target, this.up);
return ret;
}
/** Get view direction for LOD calculations */
getViewDirection(): vec3 {
return vec3.clone(this.forward);
}
}

View File

@@ -1,103 +1,113 @@
import { WebGPUContext } from './WebGPUContext';
/** Grid for the water surface - WebGPU version */
/** Grid for the water surface */
export class Grid {
private indices: Uint32Array = new Uint32Array(0);
private vertices: Float32Array = new Float32Array(0);
private vertexBuffer: GPUBuffer | null = null;
private indexBuffer: GPUBuffer | null = null;
private indices: number[] = [];
private lineIndices: number[] = [];
private vertices: number[] = [];
private vao: WebGLVertexArrayObject | null = null;
private lineVao: WebGLVertexArrayObject | null = null;
private size: number;
private indexCount: number = 0;
private offsetX: number;
private offsetY: number;
private scale: number;
constructor(size: number = 128) {
constructor(size: number = 128, offsetX: number = 0, offsetY: number = 0, scale: number = 1) {
this.size = size;
this.offsetX = offsetX;
this.offsetY = offsetY;
this.scale = scale;
}
generate(): void {
const indices: number[] = [];
const vertices: number[] = [];
this.indices = [];
this.lineIndices = [];
this.vertices = [];
for (let j = 0; j <= this.size; ++j) {
for (let i = 0; i <= this.size; ++i) {
// Generate Vertices with UV coordinates
const x = i / this.size;
const y = j / this.size;
// Generate Vertices normalized to 0-1, then scale and offset
// Grid is on XY plane (horizontal), Z is up
const u = i / this.size;
const v = j / this.size;
const x = (u - 0.5) * this.scale + this.offsetX;
const y = (v - 0.5) * this.scale + this.offsetY;
const z = 0;
const u = x;
const v = y;
vertices.push(x, y, z, u, v); // position + UV
this.vertices.push(x, y, z);
if (i < this.size && j < this.size) { // Skip edges
const row1 = j * (this.size + 1);
const row2 = (j + 1) * (this.size + 1);
// triangle 1
indices.push(row1 + i);
indices.push(row1 + i + 1);
indices.push(row2 + i + 1);
this.indices.push(row1 + i);
this.indices.push(row1 + i + 1);
this.indices.push(row2 + i + 1);
// triangle 2
indices.push(row1 + i);
indices.push(row2 + i + 1);
indices.push(row2 + i);
this.indices.push(row1 + i);
this.indices.push(row2 + i + 1);
this.indices.push(row2 + i);
}
// Generate line indices for wireframe
if (i < this.size) {
const currentVertex = j * (this.size + 1) + i;
this.lineIndices.push(currentVertex, currentVertex + 1);
}
if (j < this.size) {
const currentVertex = j * (this.size + 1) + i;
this.lineIndices.push(currentVertex, currentVertex + (this.size + 1));
}
}
}
}
this.vertices = new Float32Array(vertices);
this.indices = new Uint32Array(indices);
this.indexCount = this.indices.length;
}
initBuffers(gpuContext: WebGPUContext): void {
initVAO(gl: WebGL2RenderingContext): void {
this.generate();
const device = gpuContext.getDevice();
// Create VAO for filled triangles
this.vao = gl.createVertexArray();
gl.bindVertexArray(this.vao);
// Create vertex buffer
this.vertexBuffer = device.createBuffer({
size: this.vertices.byteLength,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
mappedAtCreation: true,
});
new Float32Array(this.vertexBuffer.getMappedRange()).set(this.vertices);
this.vertexBuffer.unmap();
const vboGrid: WebGLBuffer | null = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vboGrid);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(this.vertices), gl.STATIC_DRAW);
// Create index buffer
this.indexBuffer = device.createBuffer({
size: this.indices.byteLength,
usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
mappedAtCreation: true,
});
new Uint32Array(this.indexBuffer.getMappedRange()).set(this.indices);
this.indexBuffer.unmap();
const iboGrid: WebGLBuffer | null = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, iboGrid);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint32Array(this.indices), gl.STATIC_DRAW);
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 3 * Float32Array.BYTES_PER_ELEMENT, 0);
gl.enableVertexAttribArray(0);
gl.bindVertexArray(null);
// Create VAO for wireframe lines
this.lineVao = gl.createVertexArray();
gl.bindVertexArray(this.lineVao);
gl.bindBuffer(gl.ARRAY_BUFFER, vboGrid);
const iboLine: WebGLBuffer | null = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, iboLine);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint32Array(this.lineIndices), gl.STATIC_DRAW);
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 3 * Float32Array.BYTES_PER_ELEMENT, 0);
gl.enableVertexAttribArray(0);
gl.bindVertexArray(null);
}
draw(renderPass: GPURenderPassEncoder, wireframe: boolean = false): void {
if (!this.vertexBuffer || !this.indexBuffer) return;
renderPass.setVertexBuffer(0, this.vertexBuffer);
renderPass.setIndexBuffer(this.indexBuffer, 'uint32');
if (wireframe) {
// For wireframe, we'd need a different topology or to draw lines
// WebGPU doesn't support LINE_LOOP like WebGL, so we draw as line-list
// This would require regenerating indices for line rendering
renderPass.drawIndexed(this.indexCount);
} else {
renderPass.drawIndexed(this.indexCount);
draw(gl: WebGL2RenderingContext, wireframe: boolean = false): void {
if (wireframe && this.lineVao) {
gl.bindVertexArray(this.lineVao);
gl.drawElements(gl.LINES, this.lineIndices.length, gl.UNSIGNED_INT, 0);
gl.bindVertexArray(null);
} else if (this.vao) {
gl.bindVertexArray(this.vao);
gl.drawElements(gl.TRIANGLES, this.indices.length, gl.UNSIGNED_INT, 0);
gl.bindVertexArray(null);
}
}
getIndexCount(): number {
return this.indexCount;
}
getVertexBuffer(): GPUBuffer | null {
return this.vertexBuffer;
}
getIndexBuffer(): GPUBuffer | null {
return this.indexBuffer;
return this.indices.length;
}
}

View File

@@ -1,11 +0,0 @@
import { vec3, mat4 } from 'gl-matrix';
/** Camera interface that both camera types implement */
export interface ICamera {
pos: vec3;
target: vec3;
up: vec3;
getViewMatrix(): mat4;
getViewDirection(): vec3;
}

167
src/OceanLOD.ts Normal file
View File

@@ -0,0 +1,167 @@
import { vec3, vec4, mat4 } from 'gl-matrix';
/**
* Projected Grid Ocean - Based on the projected grid algorithm.
* Uses a separate projector that can be adjusted to avoid backfiring.
* The grid is created in projector space and projected onto the ocean plane.
*/
export class ProjectedOcean {
private vao: WebGLVertexArrayObject | null = null;
private lineVao: WebGLVertexArrayObject | null = null;
private indexBuffer: WebGLBuffer | null = null;
private vertexBuffer: WebGLBuffer | null = null;
private indexCount: number = 0;
private lineIndexCount: number = 0;
// Grid resolution
private readonly GRID_SIZE_X = 400;
private readonly GRID_SIZE_Y = 400;
// Ocean plane parameters (Z = 0 plane, normal pointing up)
private readonly OCEAN_LEVEL = 0.0;
private readonly MAX_WAVE_HEIGHT = 1.5; // Maximum displacement above ocean level
private readonly MIN_WAVE_HEIGHT = -0.5; // Maximum displacement below ocean level
// Projector parameters
private readonly MIN_PROJECTOR_HEIGHT = 5.0; // Minimum height above upper bound
// Matrices for the shader
public projectorMatrix: mat4 = mat4.create();
public rangeMatrix: mat4 = mat4.create();
constructor() {}
/** Generate the grid vertices (in [0,1] range) */
initVAO(gl: WebGL2RenderingContext): void {
const vertices: number[] = [];
const indices: number[] = [];
// Create grid in [0,1] range - will be transformed by projector matrix
for (let y = 0; y <= this.GRID_SIZE_Y; y++) {
for (let x = 0; x <= this.GRID_SIZE_X; x++) {
const u = x / this.GRID_SIZE_X;
const v = y / this.GRID_SIZE_Y;
vertices.push(u, v);
}
}
// Create indices (counter-clockwise winding when viewed from above, Z up)
for (let y = 0; y < this.GRID_SIZE_Y; y++) {
for (let x = 0; x < this.GRID_SIZE_X; x++) {
const topLeft = y * (this.GRID_SIZE_X + 1) + x;
const topRight = topLeft + 1;
const bottomLeft = (y + 1) * (this.GRID_SIZE_X + 1) + x;
const bottomRight = bottomLeft + 1;
// CCW winding for front face visible from +Z (above)
indices.push(topLeft, topRight, bottomLeft);
indices.push(topRight, bottomRight, bottomLeft);
}
}
this.indexCount = indices.length;
// Create line indices for wireframe
const lineIndices: number[] = [];
for (let y = 0; y <= this.GRID_SIZE_Y; y++) {
for (let x = 0; x <= this.GRID_SIZE_X; x++) {
const currentVertex = y * (this.GRID_SIZE_X + 1) + x;
// Horizontal line
if (x < this.GRID_SIZE_X) {
lineIndices.push(currentVertex, currentVertex + 1);
}
// Vertical line
if (y < this.GRID_SIZE_Y) {
lineIndices.push(currentVertex, currentVertex + (this.GRID_SIZE_X + 1));
}
}
}
this.lineIndexCount = lineIndices.length;
// Create vertex buffer (shared between both VAOs)
this.vertexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.STATIC_DRAW);
// Create VAO for filled triangles
this.vao = gl.createVertexArray();
gl.bindVertexArray(this.vao);
gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
this.indexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.indexBuffer);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint32Array(indices), gl.STATIC_DRAW);
gl.bindVertexArray(null);
// Create VAO for wireframe lines
this.lineVao = gl.createVertexArray();
gl.bindVertexArray(this.lineVao);
gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
const lineIndexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, lineIndexBuffer);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint32Array(lineIndices), gl.STATIC_DRAW);
gl.bindVertexArray(null);
}
/**
* Update the projector matrices based on camera position.
* We use the camera's own view-projection to ensure screen coverage.
*/
updateProjector(cameraPos: vec3, cameraForward: vec3, viewMatrix: mat4, projMatrix: mat4): void {
// Use camera's view-projection directly
const viewProj = mat4.create();
mat4.multiply(viewProj, projMatrix, viewMatrix);
// Invert to get unprojection matrix
mat4.invert(this.projectorMatrix, viewProj);
// Range matrix maps [0,1] grid to [-1,1] clip space
this.calculateRangeMatrix(cameraPos, viewMatrix, projMatrix, viewProj);
}
/**
* Calculate the range conversion matrix to focus geometry on visible area
* For simplicity and to ensure horizon coverage, we use the full clip space range
*/
private calculateRangeMatrix(
cameraPos: vec3,
viewMatrix: mat4,
projMatrix: mat4,
projectorViewProj: mat4
): void {
// Use full clip space [-1, 1] to ensure complete coverage including horizon
// The grid [0,1] maps to [-1,1] in projector clip space
mat4.identity(this.rangeMatrix);
this.rangeMatrix[0] = 2.0; // Scale X: [0,1] -> [0,2]
this.rangeMatrix[5] = 2.0; // Scale Y: [0,1] -> [0,2]
this.rangeMatrix[10] = 2.0; // Scale Z
this.rangeMatrix[12] = -1.0; // Translate X: [0,2] -> [-1,1]
this.rangeMatrix[13] = -1.0; // Translate Y: [0,2] -> [-1,1]
this.rangeMatrix[14] = -1.0; // Translate Z
}
draw(gl: WebGL2RenderingContext, wireframe: boolean = false): void {
if (wireframe && this.lineVao) {
gl.bindVertexArray(this.lineVao);
gl.drawElements(gl.LINES, this.lineIndexCount, gl.UNSIGNED_INT, 0);
gl.bindVertexArray(null);
} else if (this.vao) {
gl.bindVertexArray(this.vao);
gl.drawElements(gl.TRIANGLES, this.indexCount, gl.UNSIGNED_INT, 0);
gl.bindVertexArray(null);
}
}
getIndexCount(): number {
return this.indexCount;
}
}

View File

@@ -1,16 +1,12 @@
import { WebGPUContext } from './WebGPUContext';
/** Skybox cube for rendering the sky - WebGPU version */
/** Skybox cube for rendering the sky */
export class Skybox {
private vertexBuffer: GPUBuffer | null = null;
private indexBuffer: GPUBuffer | null = null;
private vao: WebGLVertexArrayObject | null = null;
private vbo: WebGLBuffer | null = null;
private indexCount: number = 0;
constructor() {}
initBuffers(gpuContext: WebGPUContext): void {
const device = gpuContext.getDevice();
initVAO(gl: WebGL2RenderingContext): void {
// Cube vertices - positions only
const vertices = new Float32Array([
// Front face
@@ -56,38 +52,34 @@ export class Skybox {
this.indexCount = indices.length;
// Create vertex buffer
this.vertexBuffer = device.createBuffer({
size: vertices.byteLength,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
mappedAtCreation: true,
});
new Float32Array(this.vertexBuffer.getMappedRange()).set(vertices);
this.vertexBuffer.unmap();
this.vao = gl.createVertexArray();
gl.bindVertexArray(this.vao);
// Create index buffer
this.indexBuffer = device.createBuffer({
size: indices.byteLength,
usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
mappedAtCreation: true,
});
new Uint16Array(this.indexBuffer.getMappedRange()).set(indices);
this.indexBuffer.unmap();
this.vbo = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, this.vbo);
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
const ibo = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibo);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW);
// Position attribute
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 0, 0);
gl.bindVertexArray(null);
}
draw(renderPass: GPURenderPassEncoder): void {
if (!this.vertexBuffer || !this.indexBuffer) return;
draw(gl: WebGL2RenderingContext): void {
if (!this.vao) return;
renderPass.setVertexBuffer(0, this.vertexBuffer);
renderPass.setIndexBuffer(this.indexBuffer, 'uint16');
renderPass.drawIndexed(this.indexCount);
}
// Disable face culling for skybox (we're inside the cube)
gl.disable(gl.CULL_FACE);
getVertexBuffer(): GPUBuffer | null {
return this.vertexBuffer;
}
gl.bindVertexArray(this.vao);
gl.drawElements(gl.TRIANGLES, this.indexCount, gl.UNSIGNED_SHORT, 0);
gl.bindVertexArray(null);
getIndexBuffer(): GPUBuffer | null {
return this.indexBuffer;
gl.enable(gl.CULL_FACE);
}
}

View File

@@ -1,110 +0,0 @@
/** WebGPU Context Manager */
export class WebGPUContext {
adapter: GPUAdapter | null = null;
device: GPUDevice | null = null;
context: GPUCanvasContext | null = null;
canvasFormat: GPUTextureFormat = 'bgra8unorm';
canvas: HTMLCanvasElement;
constructor(canvas: HTMLCanvasElement) {
this.canvas = canvas;
}
async initialize(): Promise<boolean> {
// Check WebGPU support
if (!navigator.gpu) {
console.error('WebGPU is not supported in this browser.');
return false;
}
// Request adapter
this.adapter = await navigator.gpu.requestAdapter();
if (!this.adapter) {
console.error('Failed to get GPU adapter.');
return false;
}
// Request device
this.device = await this.adapter.requestDevice();
if (!this.device) {
console.error('Failed to get GPU device.');
return false;
}
// Get canvas context
this.context = this.canvas.getContext('webgpu') as GPUCanvasContext;
if (!this.context) {
console.error('Failed to get WebGPU canvas context.');
return false;
}
// Configure canvas
this.canvasFormat = navigator.gpu.getPreferredCanvasFormat();
this.context.configure({
device: this.device,
format: this.canvasFormat,
alphaMode: 'opaque',
});
console.log('WebGPU initialized successfully');
return true;
}
getDevice(): GPUDevice {
if (!this.device) {
throw new Error('Device not initialized');
}
return this.device;
}
getContext(): GPUCanvasContext {
if (!this.context) {
throw new Error('Context not initialized');
}
return this.context;
}
getCurrentTexture(): GPUTexture {
return this.getContext().getCurrentTexture();
}
createBuffer(descriptor: GPUBufferDescriptor): GPUBuffer {
return this.getDevice().createBuffer(descriptor);
}
createTexture(descriptor: GPUTextureDescriptor): GPUTexture {
return this.getDevice().createTexture(descriptor);
}
createSampler(descriptor: GPUSamplerDescriptor): GPUSampler {
return this.getDevice().createSampler(descriptor);
}
createShaderModule(code: string): GPUShaderModule {
return this.getDevice().createShaderModule({ code });
}
createRenderPipeline(descriptor: GPURenderPipelineDescriptor): GPURenderPipeline {
return this.getDevice().createRenderPipeline(descriptor);
}
createBindGroup(descriptor: GPUBindGroupDescriptor): GPUBindGroup {
return this.getDevice().createBindGroup(descriptor);
}
createBindGroupLayout(descriptor: GPUBindGroupLayoutDescriptor): GPUBindGroupLayout {
return this.getDevice().createBindGroupLayout(descriptor);
}
createCommandEncoder(): GPUCommandEncoder {
return this.getDevice().createCommandEncoder();
}
submitCommands(commandBuffers: GPUCommandBuffer[]): void {
this.getDevice().queue.submit(commandBuffers);
}
writeBuffer(buffer: GPUBuffer, data: BufferSource, offset: number = 0): void {
this.getDevice().queue.writeBuffer(buffer, offset, data);
}
}

View File

@@ -1,7 +1,7 @@
// Configuration Constants
export const GRID_SIZE = 128;
export const NOISE_TEXTURE_WIDTH = 256;
export const NOISE_TEXTURE_HEIGHT = 256;
export const NOISE_TEXTURE_WIDTH = 1024;
export const NOISE_TEXTURE_HEIGHT = 1024;
export const CANVAS_WIDTH = 800;
export const CANVAS_HEIGHT = 600;
export const FOV = 1.0;

View File

@@ -1,85 +1,20 @@
import { vec3, mat4 } from 'gl-matrix';
import { ICamera } from './ICamera';
import { OrbitalCamera } from './Camera';
import { FPSCamera } from './FPSCamera';
import { Grid } from './Grid';
import { vec3, vec4, mat4 } from 'gl-matrix';
import { Camera } from './Camera';
import { ProjectedOcean } from './OceanLOD';
import { Skybox } from './Skybox';
import { WebGPUContext } from './WebGPUContext';
import {
noiseVertexShader, noiseFragmentShader,
oceanVertexShader, oceanFragmentShader,
skyboxVertexShader, skyboxFragmentShader
} from './shaders.wgsl';
import { createProgram } from './Shader';
import * as Config from './constants';
// Global state
let gpuContext: WebGPUContext;
let viewportWidth = 0;
let viewportHeight = 0;
var gl: WebGL2RenderingContext;
var viewportWidth = 0;
var viewportHeight = 0;
// Render pipelines
let noisePipeline: GPURenderPipeline;
let oceanPipeline: GPURenderPipeline;
let skyboxPipeline: GPURenderPipeline;
/** A camera that always looks at the world origin. Can have an offset and be rotated. */
// Moved to Camera.ts
// Textures and buffers
let noiseTexture: GPUTexture;
let noiseTextureView: GPUTextureView;
let depthTexture: GPUTexture;
let depthTextureView: GPUTextureView;
// Uniform buffers
let noiseUniformBuffer: GPUBuffer;
let oceanUniformBuffer: GPUBuffer;
let skyboxUniformBuffer: GPUBuffer;
// Bind groups
let noiseBindGroup: GPUBindGroup;
let oceanBindGroup: GPUBindGroup;
let skyboxBindGroup: GPUBindGroup;
// Sampler
let linearSampler: GPUSampler;
// Framerate measurement
let timeSpent = 0.0;
let lastTime = Date.now();
let counter = 0.0;
let fps = 0;
let fpsDisplay: HTMLElement | null = null;
let frameTimeDisplay: HTMLElement | null = null;
// Input states
let mouseXVel = 0;
let mouseYVel = 0;
let keyboardRotationX = 0;
let keyboardRotationY = 0;
let keyboardZoom = 0;
let keysPressed: Set<string> = new Set();
// Objects and state
let camera: ICamera;
let orbitalCamera: OrbitalCamera;
let fpsCamera: FPSCamera;
let oceanGrid: Grid;
let skybox: Skybox;
let curRotX = Config.CAMERA_DEFAULT_ROT_X;
let curRotY = Config.CAMERA_DEFAULT_ROT_Y;
// Camera modes
let cameraMode: 'orbital' | 'fps' = 'orbital';
let moveSpeed = 0.08;
let fastMoveSpeed = 0.20;
// Rendering modes
let wireframeMode = false;
// Animation control
let isPaused = false;
let animationSpeed = 1.0;
/** Initialize WebGPU context and get canvas sizes */
async function initWebGPU(canvas: HTMLCanvasElement): Promise<(canvas: HTMLCanvasElement) => void> {
/** 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;
@@ -90,590 +25,418 @@ async function initWebGPU(canvas: HTMLCanvasElement): Promise<(canvas: HTMLCanva
viewportWidth = displayWidth;
viewportHeight = displayHeight;
// Recreate depth texture on resize
if (gpuContext && depthTexture) {
depthTexture.destroy();
createDepthTexture();
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);
gpuContext = new WebGPUContext(canvas);
const initialized = await gpuContext.initialize();
if (!initialized) {
console.error("Unable to initialize WebGPU. Your browser or machine may not support it.");
throw new Error("WebGPU initialization failed");
}
} 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;
}
/** Create depth texture for depth testing */
function createDepthTexture() {
const device = gpuContext.getDevice();
/** Update canvas size to fill window */
// Moved inline below
depthTexture = device.createTexture({
size: { width: viewportWidth, height: viewportHeight },
format: 'depth24plus',
usage: GPUTextureUsage.RENDER_ATTACHMENT,
});
depthTextureView = depthTexture.createView();
/** 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);
}
/** Create noise render texture */
function createNoiseTexture() {
const device = gpuContext.getDevice();
/** Get shader source by HTML-Element<id> */
// Moved to Shader.ts
noiseTexture = device.createTexture({
size: {
width: Config.NOISE_TEXTURE_WIDTH,
height: Config.NOISE_TEXTURE_HEIGHT
},
format: 'rgba16float',
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
});
noiseTextureView = noiseTexture.createView();
/** 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");
}
/** Create sampler for texture sampling */
function createSampler() {
const device = gpuContext.getDevice();
/** 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);
linearSampler = device.createSampler({
addressModeU: 'repeat',
addressModeV: 'repeat',
magFilter: 'linear',
minFilter: 'linear',
});
// 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.");
}
/** Create uniform buffers */
function createUniformBuffers() {
const device = gpuContext.getDevice();
// Noise uniform: time (f32, 4 bytes) - needs padding to 16 bytes
noiseUniformBuffer = device.createBuffer({
size: 16, // Padded for alignment
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
// Ocean uniforms: MVP matrices (3 x mat4 = 192 bytes) + eyePos (vec3, 12 bytes + 4 padding = 16) = 208 bytes
oceanUniformBuffer = device.createBuffer({
size: 208,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
// Skybox uniforms: view (mat4, 64) + projection (mat4, 64) + sunDir (vec3, 12 + 4 padding = 16) = 144 bytes
skyboxUniformBuffer = device.createBuffer({
size: 144,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
gl.bindFramebuffer(gl.FRAMEBUFFER, null); //Reset to default framebuffer
}
/** Create render pipelines */
function createPipelines() {
const device = gpuContext.getDevice();
const presentationFormat = gpuContext.getContext().getCurrentTexture().format;
// --- Noise Pipeline ---
const noiseVertexModule = device.createShaderModule({ code: noiseVertexShader });
const noiseFragmentModule = device.createShaderModule({ code: noiseFragmentShader });
const noiseBindGroupLayout = device.createBindGroupLayout({
entries: [{
binding: 0,
visibility: GPUShaderStage.FRAGMENT,
buffer: { type: 'uniform' }
}]
});
noisePipeline = device.createRenderPipeline({
layout: device.createPipelineLayout({
bindGroupLayouts: [noiseBindGroupLayout]
}),
vertex: {
module: noiseVertexModule,
entryPoint: 'main',
},
fragment: {
module: noiseFragmentModule,
entryPoint: 'main',
targets: [{ format: 'rgba16float' }]
},
primitive: {
topology: 'triangle-list',
},
});
noiseBindGroup = device.createBindGroup({
layout: noiseBindGroupLayout,
entries: [{
binding: 0,
resource: { buffer: noiseUniformBuffer }
}]
});
// --- Ocean Pipeline ---
const oceanVertexModule = device.createShaderModule({ code: oceanVertexShader });
const oceanFragmentModule = device.createShaderModule({ code: oceanFragmentShader });
const oceanBindGroupLayout = device.createBindGroupLayout({
entries: [
{
binding: 0,
visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
buffer: { type: 'uniform' }
},
{
binding: 1,
visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
texture: { sampleType: 'float' }
},
{
binding: 2,
visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
sampler: { type: 'filtering' }
}
]
});
oceanPipeline = device.createRenderPipeline({
layout: device.createPipelineLayout({
bindGroupLayouts: [oceanBindGroupLayout]
}),
vertex: {
module: oceanVertexModule,
entryPoint: 'main',
buffers: [{
arrayStride: 5 * 4, // 5 floats: x, y, z, u, v
attributes: [
{ shaderLocation: 0, offset: 0, format: 'float32x3' }, // position
{ shaderLocation: 1, offset: 12, format: 'float32x2' }, // uv
]
}]
},
fragment: {
module: oceanFragmentModule,
entryPoint: 'main',
targets: [{ format: presentationFormat }]
},
primitive: {
topology: wireframeMode ? 'line-list' : 'triangle-list',
cullMode: 'back',
},
depthStencil: {
format: 'depth24plus',
depthWriteEnabled: true,
depthCompare: 'less',
},
});
oceanBindGroup = device.createBindGroup({
layout: oceanBindGroupLayout,
entries: [
{ binding: 0, resource: { buffer: oceanUniformBuffer } },
{ binding: 1, resource: noiseTextureView },
{ binding: 2, resource: linearSampler }
]
});
// --- Skybox Pipeline ---
const skyboxVertexModule = device.createShaderModule({ code: skyboxVertexShader });
const skyboxFragmentModule = device.createShaderModule({ code: skyboxFragmentShader });
const skyboxBindGroupLayout = device.createBindGroupLayout({
entries: [{
binding: 0,
visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
buffer: { type: 'uniform' }
}]
});
skyboxPipeline = device.createRenderPipeline({
layout: device.createPipelineLayout({
bindGroupLayouts: [skyboxBindGroupLayout]
}),
vertex: {
module: skyboxVertexModule,
entryPoint: 'main',
buffers: [{
arrayStride: 3 * 4, // 3 floats: x, y, z
attributes: [
{ shaderLocation: 0, offset: 0, format: 'float32x3' }
]
}]
},
fragment: {
module: skyboxFragmentModule,
entryPoint: 'main',
targets: [{ format: presentationFormat }]
},
primitive: {
topology: 'triangle-list',
cullMode: 'none', // No culling for skybox
},
depthStencil: {
format: 'depth24plus',
depthWriteEnabled: false, // Don't write to depth buffer
depthCompare: 'always', // Always pass depth test
},
});
skyboxBindGroup = device.createBindGroup({
layout: skyboxBindGroupLayout,
entries: [{
binding: 0,
resource: { buffer: skyboxUniformBuffer }
}]
});
}
/** Main draw function */
/** 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() {
if (isPaused) {
requestAnimationFrame(drawScene);
return;
}
fps++;
const now = Date.now();
const delta = now - lastTime;
timeSpent += delta * animationSpeed / 1000.0; // Convert to seconds and apply speed
let now = new Date();
let delta = now.getTime() - lastTime;
timeSpent += delta;
if ((counter += delta) >= Config.FPS_UPDATE_INTERVAL) {
counter = 0;
const frameTime = delta.toFixed(2);
if (fpsDisplay) {
fpsDisplay.textContent = `FPS: ${fps}`;
}
if (frameTimeDisplay) {
frameTimeDisplay.textContent = `Frame: ${frameTime}ms`;
}
fps = 0;
}
lastTime = now;
lastTime = now.getTime();
const device = gpuContext.getDevice();
const queue = device.queue;
const context = gpuContext.getContext();
// --- First Pass: Generate Perlin Noise ---
{
const timeData = new Float32Array([timeSpent, 0, 0, 0]); // Pad to 16 bytes
queue.writeBuffer(noiseUniformBuffer, 0, timeData);
const commandEncoder = device.createCommandEncoder();
const renderPass = commandEncoder.beginRenderPass({
colorAttachments: [{
view: noiseTextureView,
clearValue: { r: 1, g: 1, b: 1, a: 1 },
loadOp: 'clear',
storeOp: 'store',
}]
});
renderPass.setPipeline(noisePipeline);
renderPass.setBindGroup(0, noiseBindGroup);
renderPass.draw(6); // Fullscreen quad (2 triangles)
renderPass.end();
queue.submit([commandEncoder.finish()]);
}
// --- Second Pass: Render Scene (Skybox + Ocean) ---
{
const projection = mat4.create();
mat4.perspective(projection, Config.FOV, viewportWidth / viewportHeight, Config.NEAR_PLANE, Config.FAR_PLANE);
// Handle camera movement
if (cameraMode === 'fps') {
camera = fpsCamera;
handleFPSCameraMovement();
if (mouseXVel !== 0 || mouseYVel !== 0) {
fpsCamera.rotate(mouseXVel, mouseYVel);
mouseXVel = 0;
mouseYVel = 0;
}
} else {
camera = orbitalCamera;
orbitalCamera.setOffset(Config.CAMERA_DEFAULT_OFFSET + keyboardZoom);
orbitalCamera.setRotationX((curRotX += mouseYVel * Config.MOUSE_SENSITIVITY + keyboardRotationX));
orbitalCamera.setRotationY((curRotY += mouseXVel * Config.MOUSE_SENSITIVITY + keyboardRotationY));
}
const view = camera.getViewMatrix();
// Sun direction (matches the one in ocean shader)
const sunDirection = vec3.fromValues(0.3, 0.5, 0.8);
vec3.normalize(sunDirection, sunDirection);
// Update skybox uniforms
//--- Render pass -> Skybox first (no depth write) ---
{
const skyboxData = new Float32Array(36); // 2 mat4 + vec3 + padding
skyboxData.set(view, 0);
skyboxData.set(projection, 16);
skyboxData.set(sunDirection, 32);
queue.writeBuffer(skyboxUniformBuffer, 0, skyboxData);
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;
}
// Update ocean uniforms
{
const model = mat4.create();
mat4.translate(model, model, vec3.fromValues(-0.5, -0.5, 0.0));
var view = camera.getViewMatrix();
const oceanData = new Float32Array(52); // 3 mat4 + vec3 + padding
oceanData.set(view, 0);
oceanData.set(model, 16);
oceanData.set(projection, 32);
oceanData.set(camera.pos, 48);
queue.writeBuffer(oceanUniformBuffer, 0, oceanData);
// 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);
}
const commandEncoder = device.createCommandEncoder();
const textureView = context.getCurrentTexture().createView();
// 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
const renderPass = commandEncoder.beginRenderPass({
colorAttachments: [{
view: textureView,
clearValue: { r: 0.0, g: 0.0, b: 0.0, a: 1.0 },
loadOp: 'clear',
storeOp: 'store',
}],
depthStencilAttachment: {
view: depthTextureView,
depthClearValue: 1.0,
depthLoadOp: 'clear',
depthStoreOp: 'store',
// 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);
}
});
// Draw skybox
renderPass.setPipeline(skyboxPipeline);
renderPass.setBindGroup(0, skyboxBindGroup);
skybox.draw(renderPass);
// Draw ocean
renderPass.setPipeline(oceanPipeline);
renderPass.setBindGroup(0, oceanBindGroup);
oceanGrid.draw(renderPass, wireframeMode);
renderPass.end();
queue.submit([commandEncoder.finish()]);
}
requestAnimationFrame(drawScene);
}
/** Handle FPS camera movement */
function handleFPSCameraMovement() {
function handleCameraMovement() {
const speed = keysPressed.has('Shift') ? fastMoveSpeed : moveSpeed;
// WASD for horizontal movement
if (keysPressed.has('w') || keysPressed.has('W')) {
fpsCamera.moveForward(speed);
camera.moveForward(speed);
}
if (keysPressed.has('s') || keysPressed.has('S')) {
fpsCamera.moveForward(-speed);
camera.moveForward(-speed);
}
if (keysPressed.has('a') || keysPressed.has('A')) {
fpsCamera.moveRight(-speed);
camera.moveRight(-speed);
}
if (keysPressed.has('d') || keysPressed.has('D')) {
fpsCamera.moveRight(speed);
camera.moveRight(speed);
}
// Q/E for vertical movement
if (keysPressed.has('q') || keysPressed.has('Q')) {
fpsCamera.moveUp(-speed);
camera.moveUp(-speed);
}
if (keysPressed.has('e') || keysPressed.has('E')) {
fpsCamera.moveUp(speed);
camera.moveUp(speed);
}
// Space to go up, Ctrl to go down
if (keysPressed.has(' ')) {
fpsCamera.moveUp(speed);
camera.moveUp(speed);
}
if (keysPressed.has('Control')) {
fpsCamera.moveUp(-speed);
camera.moveUp(-speed);
}
}
/** Handle keyboard input for orbital camera */
function handleKeyboardInput() {
keyboardRotationX = 0;
keyboardRotationY = 0;
if (keysPressed.has('ArrowUp')) {
keyboardRotationX = Config.KEYBOARD_ROTATION_SPEED;
}
if (keysPressed.has('ArrowDown')) {
keyboardRotationX = -Config.KEYBOARD_ROTATION_SPEED;
}
if (keysPressed.has('ArrowLeft')) {
keyboardRotationY = Config.KEYBOARD_ROTATION_SPEED;
}
if (keysPressed.has('ArrowRight')) {
keyboardRotationY = -Config.KEYBOARD_ROTATION_SPEED;
}
if (keysPressed.has('+') || keysPressed.has('=')) {
keyboardZoom -= Config.KEYBOARD_ZOOM_SPEED;
}
if (keysPressed.has('-') || keysPressed.has('_')) {
keyboardZoom += Config.KEYBOARD_ZOOM_SPEED;
}
}
/** Main entry point */
async function main() {
function main() {
const canvas: HTMLCanvasElement = <HTMLCanvasElement>document.getElementById("window");
fpsDisplay = document.getElementById("fps-counter");
frameTimeDisplay = document.getElementById("frame-time");
try {
const updateCanvasSize = await initWebGPU(canvas);
const updateCanvasSize = initGL(canvas);
if (!updateCanvasSize) {
console.error("Failed to initialize WebGL");
return;
}
// Create resources
createDepthTexture();
createNoiseTexture();
createSampler();
createUniformBuffers();
// Initialize geometry
oceanGrid = new Grid(Config.GRID_SIZE);
oceanGrid.initBuffers(gpuContext);
skybox = new Skybox();
skybox.initBuffers(gpuContext);
// Create pipelines after geometry
createPipelines();
// Initialize cameras
orbitalCamera = new OrbitalCamera();
fpsCamera = new FPSCamera();
camera = orbitalCamera;
console.log('WebGPU initialized - Press C to toggle between FPS and Orbital cameras');
// Mouse controls
let drag = false;
let previousPosX: number | null = null;
let previousPosY: number | null = null;
canvas.addEventListener('mousedown', () => {
var drag = false;
var previousPosX: number | null;
var previousPosY: number | null;
canvas.addEventListener('mousedown', function (evt) {
drag = true;
});
canvas.addEventListener('mousemove', (evt) => {
}, false);
canvas.addEventListener('mousemove', function (evt) {
if (drag) {
if (previousPosX == null || previousPosY == null) {
previousPosX = evt.x;
previousPosY = evt.y;
}
const mousePosX = evt.x;
const mousePosY = evt.y;
var mousePosX = evt.x;
var mousePosY = evt.y;
mouseXVel = (mousePosX - previousPosX);
mouseYVel = (mousePosY - previousPosY);
previousPosX = mousePosX;
previousPosY = mousePosY;
}
});
const deactivateMouseMovement = () => {
}, false);
var deactivateMouseMovement = function () {
previousPosX = null;
previousPosY = null;
mouseXVel = 0.0;
mouseYVel = 0.0;
drag = false;
};
canvas.addEventListener('mouseup', deactivateMouseMovement);
canvas.addEventListener('mouseleave', deactivateMouseMovement);
}
canvas.addEventListener('mouseup', deactivateMouseMovement, false);
canvas.addEventListener('mouseleave', deactivateMouseMovement, false);
// Keyboard controls
window.addEventListener('keydown', (evt) => {
keysPressed.add(evt.key);
// Toggle camera mode
if (evt.key === 'c' || evt.key === 'C') {
cameraMode = cameraMode === 'fps' ? 'orbital' : 'fps';
console.log(`Camera mode: ${cameraMode.toUpperCase()}`);
updateCameraModeDisplay();
}
// Reset camera
// Reset camera on 'R' key
if (evt.key === 'r' || evt.key === 'R') {
if (cameraMode === 'fps') {
fpsCamera = new FPSCamera();
camera = fpsCamera;
} else {
curRotX = Config.CAMERA_DEFAULT_ROT_X;
curRotY = Config.CAMERA_DEFAULT_ROT_Y;
keyboardZoom = 0;
}
console.log('Camera reset');
camera = new Camera(); // Reset to initial position
}
// Wireframe toggle
if (evt.key === 'f' || evt.key === 'F') {
wireframeMode = !wireframeMode;
console.log(`Wireframe: ${wireframeMode ? 'ON' : 'OFF'}`);
createPipelines(); // Recreate pipeline with new topology
}
// Pause/Play
if (evt.key === 'p' || evt.key === 'P') {
isPaused = !isPaused;
console.log(`Animation: ${isPaused ? 'PAUSED' : 'PLAYING'}`);
}
// Animation speed controls
if (evt.key === '0') {
animationSpeed = 1.0;
console.log(`Speed: ${animationSpeed}x`);
} else if (evt.key >= '1' && evt.key <= '5') {
animationSpeed = parseFloat(evt.key);
console.log(`Speed: ${animationSpeed}x`);
}
if (evt.key === ' ' && cameraMode === 'fps') {
// Prevent default for space to avoid page scroll
if (evt.key === ' ') {
evt.preventDefault();
}
if (cameraMode === 'orbital') {
handleKeyboardInput();
}
});
window.addEventListener('keyup', (evt) => {
keysPressed.delete(evt.key);
if (cameraMode === 'orbital') {
handleKeyboardInput();
}
});
// Window resize
// Window resize handler
window.addEventListener('resize', () => {
updateCanvasSize(canvas);
});
// Start rendering
// 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();
} catch (error) {
console.error("Failed to initialize WebGPU:", error);
alert("WebGPU is not supported in your browser. Please use Chrome 113+ or Edge 113+.");
}
}
function updateCameraModeDisplay() {
let modeText = document.getElementById('camera-mode');
if (!modeText) {
modeText = document.createElement('div');
modeText.id = 'camera-mode';
modeText.style.cssText = 'position: absolute; top: 40px; left: 10px; color: white; font-family: monospace; font-size: 14px;';
document.body.appendChild(modeText);
}
modeText.textContent = `Camera: ${cameraMode.toUpperCase()}`;
}
main();

View File

@@ -1,509 +0,0 @@
import { vec3, mat4 } from 'gl-matrix';
import { ICamera } from './ICamera';
import { OrbitalCamera } from './Camera';
import { FPSCamera } from './FPSCamera';
import { Grid } from './Grid';
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.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
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 keyboardRotationX = 0;
var keyboardRotationY = 0;
var keyboardZoom = 0;
var keysPressed: Set<string> = new Set();
/** Objects and states*/
var camera: ICamera;
var orbitalCamera: OrbitalCamera;
var fpsCamera: FPSCamera;
var oceanGrid: Grid;
var skybox: Skybox;
var curRotX = Config.CAMERA_DEFAULT_ROT_X;
var curRotY = Config.CAMERA_DEFAULT_ROT_Y;
/** Camera modes */
var cameraMode: 'orbital' | 'fps' = 'orbital';
var moveSpeed = 0.08;
var fastMoveSpeed = 0.20;
/** Rendering modes */
var wireframeMode = false;
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();
// Two Rendering passes. The first one generates a perlin noise
// texture. Second one uses the textur for vertex displacement
// of a grid representing the water surface.
//--- First render pass -> Perlin Noise (it updates the perlin noise texture)
{
gl.bindFramebuffer(gl.FRAMEBUFFER, perlinNoiseFBO);
gl.viewport(0, 0, perlinNoiseFBOWidth, perlinNoiseFBOHeight);
//Clear buffer content
gl.clearColor(1.0, 1.0, 1.0, 1);
gl.clear(gl.COLOR_BUFFER_BIT); //No depth buffer
// Disable face culling for fullscreen quad
gl.disable(gl.CULL_FACE);
//draw a fullscreen quad
gl.bindBuffer(gl.ARRAY_BUFFER, VBO);
// There are 7 floating-point values per vertex
let stride = 5 * Float32Array.BYTES_PER_ELEMENT;
// Set up position stream
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, stride, 0);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(1, 2, gl.FLOAT, false, stride, 3 * Float32Array.BYTES_PER_ELEMENT);
gl.enableVertexAttribArray(1);
gl.useProgram(perlinNoiseProgram);
let uTime = gl.getUniformLocation(<WebGLProgram>perlinNoiseProgram, "uTime");
gl.uniform1f(uTime, timeSpent);
gl.drawArrays(gl.TRIANGLES, 0, 6); // Draw fullscreen quad
}
//--- Second render pass -> Geomtry with displacement by perlin noise texture ---
{
gl.bindFramebuffer(gl.FRAMEBUFFER, null); //Bind default framebuffer
gl.viewport(0, 0, viewportWidth, viewportHeight);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.activeTexture(gl.TEXTURE0); //Binds the texture to 0
gl.bindTexture(gl.TEXTURE_2D, textureFBO);
var projection = mat4.create();
mat4.identity(projection);
mat4.perspective(projection, Config.FOV, viewportWidth / viewportHeight, Config.NEAR_PLANE, Config.FAR_PLANE); //projection mode should actually be camera specific
// Handle camera movement and rotation based on mode
if (cameraMode === 'fps') {
// FPS camera - direct movement
camera = fpsCamera;
handleFPSCameraMovement();
// Apply mouse rotation for FPS mode
if (mouseXVel !== 0 || mouseYVel !== 0) {
fpsCamera.rotate(mouseXVel, mouseYVel);
mouseXVel = 0;
mouseYVel = 0;
}
} else {
// Orbital camera - original behavior
camera = orbitalCamera;
orbitalCamera.setOffset(Config.CAMERA_DEFAULT_OFFSET + keyboardZoom);
orbitalCamera.setRotationX((curRotX += mouseYVel * Config.MOUSE_SENSITIVITY + keyboardRotationX));
orbitalCamera.setRotationY((curRotY += mouseXVel * Config.MOUSE_SENSITIVITY + keyboardRotationY));
}
var view = camera.getViewMatrix();
// Sun direction (matches the one in ocean shader)
const sunDirection = vec3.fromValues(0.3, 0.5, 0.8);
vec3.normalize(sunDirection, sunDirection);
// Draw skybox first with depth test disabled (always behind everything)
gl.depthMask(false);
gl.disable(gl.DEPTH_TEST);
gl.disable(gl.CULL_FACE); // Disable face culling for skybox (we're inside)
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);
gl.enable(gl.CULL_FACE); // Re-enable face culling for ocean
gl.cullFace(gl.BACK); // Cull back faces for ocean
var model = mat4.create();
mat4.identity(model);
let translationCentering = vec3.create();
vec3.set(translationCentering, -0.5, -0.5, 0.0);
mat4.translate(model, model, translationCentering); //1. First Center the Surface in the origin.
gl.useProgram(defaultProgram);
let view_loc = gl.getUniformLocation(<WebGLProgram>defaultProgram, "view");
gl.uniformMatrix4fv(view_loc, false, view);
let model_loc = gl.getUniformLocation(<WebGLProgram>defaultProgram, "model");
gl.uniformMatrix4fv(model_loc, false, model);
let projection_loc = gl.getUniformLocation(<WebGLProgram>defaultProgram, "projection");
gl.uniformMatrix4fv(projection_loc, false, projection);
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);
let displacementMap_loc = gl.getUniformLocation(<WebGLProgram>defaultProgram, "displace_map");
gl.uniform1i(displacementMap_loc, 0); //Get texture from slot 0
// Draw ocean grid with wireframe mode if enabled
if (wireframeMode) {
gl.lineWidth(1.0);
}
oceanGrid.draw(gl, wireframeMode);
}
requestAnimationFrame(drawScene);
}
/** Handle FPS camera movement */
function handleFPSCameraMovement() {
const speed = keysPressed.has('Shift') ? fastMoveSpeed : moveSpeed;
// WASD for horizontal movement
if (keysPressed.has('w') || keysPressed.has('W')) {
fpsCamera.moveForward(speed);
}
if (keysPressed.has('s') || keysPressed.has('S')) {
fpsCamera.moveForward(-speed);
}
if (keysPressed.has('a') || keysPressed.has('A')) {
fpsCamera.moveRight(-speed);
}
if (keysPressed.has('d') || keysPressed.has('D')) {
fpsCamera.moveRight(speed);
}
// Q/E for vertical movement
if (keysPressed.has('q') || keysPressed.has('Q')) {
fpsCamera.moveUp(-speed);
}
if (keysPressed.has('e') || keysPressed.has('E')) {
fpsCamera.moveUp(speed);
}
// Space to go up, Ctrl to go down
if (keysPressed.has(' ')) {
fpsCamera.moveUp(speed);
}
if (keysPressed.has('Control')) {
fpsCamera.moveUp(-speed);
}
}
/** Handle keyboard input for camera controls */
function handleKeyboardInput() {
keyboardRotationX = 0;
keyboardRotationY = 0;
if (keysPressed.has('w') || keysPressed.has('W') || keysPressed.has('ArrowUp')) {
keyboardRotationX = Config.KEYBOARD_ROTATION_SPEED;
}
if (keysPressed.has('s') || keysPressed.has('S') || keysPressed.has('ArrowDown')) {
keyboardRotationX = -Config.KEYBOARD_ROTATION_SPEED;
}
if (keysPressed.has('a') || keysPressed.has('A') || keysPressed.has('ArrowLeft')) {
keyboardRotationY = Config.KEYBOARD_ROTATION_SPEED;
}
if (keysPressed.has('d') || keysPressed.has('D') || keysPressed.has('ArrowRight')) {
keyboardRotationY = -Config.KEYBOARD_ROTATION_SPEED;
}
if (keysPressed.has('q') || keysPressed.has('Q') || keysPressed.has('+')) {
keyboardZoom -= Config.KEYBOARD_ZOOM_SPEED;
}
if (keysPressed.has('e') || keysPressed.has('E') || keysPressed.has('-')) {
keyboardZoom += Config.KEYBOARD_ZOOM_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);
// Toggle camera mode with 'C' key
if (evt.key === 'c' || evt.key === 'C') {
cameraMode = cameraMode === 'fps' ? 'orbital' : 'fps';
console.log(`Camera mode: ${cameraMode.toUpperCase()}`);
// Update FPS display to show camera mode
if (fpsDisplay) {
const modeText = document.createElement('div');
modeText.id = 'camera-mode';
modeText.style.cssText = 'position: absolute; top: 40px; left: 10px; color: white; font-family: monospace;';
modeText.textContent = `Camera: ${cameraMode.toUpperCase()}`;
const existingMode = document.getElementById('camera-mode');
if (existingMode) {
existingMode.textContent = `Camera: ${cameraMode.toUpperCase()}`;
} else {
document.body.appendChild(modeText);
}
}
}
// Reset camera on 'R' key
if (evt.key === 'r' || evt.key === 'R') {
if (cameraMode === 'fps') {
fpsCamera = new FPSCamera(); // Reset to initial FPS position
camera = fpsCamera;
console.log('Camera reset to FPS default position');
} else {
curRotX = Config.CAMERA_DEFAULT_ROT_X;
curRotY = Config.CAMERA_DEFAULT_ROT_Y;
keyboardZoom = 0;
console.log('Camera reset to orbital default position');
}
}
// Prevent default for space to avoid page scroll
if (evt.key === ' ' && cameraMode === 'fps') {
evt.preventDefault();
}
// Wireframe toggle with F key
if (evt.key === 'f' || evt.key === 'F') {
wireframeMode = !wireframeMode;
console.log(`Wireframe mode: ${wireframeMode ? 'ON' : 'OFF'}`);
}
// Handle orbital camera keyboard input
if (cameraMode === 'orbital') {
handleKeyboardInput();
}
});
window.addEventListener('keyup', (evt) => {
keysPressed.delete(evt.key);
if (cameraMode === 'orbital') {
handleKeyboardInput();
}
});
// Window resize handler
window.addEventListener('resize', () => {
updateCanvasSize(canvas);
});
// Camera mode toggle from UI controls
window.addEventListener('toggleCameraMode', () => {
cameraMode = cameraMode === 'fps' ? 'orbital' : 'fps';
camera = cameraMode === 'fps' ? fpsCamera : orbitalCamera;
console.log(`Camera mode switched to: ${cameraMode.toUpperCase()}`);
// Update display
const modeText = document.createElement('div');
modeText.id = 'camera-mode';
modeText.style.cssText = 'position: absolute; top: 40px; left: 10px; color: white; font-family: monospace;';
modeText.textContent = `Camera: ${cameraMode.toUpperCase()}`;
const existingMode = document.getElementById('camera-mode');
if (existingMode) {
existingMode.textContent = `Camera: ${cameraMode.toUpperCase()}`;
} else {
document.body.appendChild(modeText);
}
});
initShaders();
initGeometry();
initFBO();
oceanGrid = new Grid(Config.GRID_SIZE);
oceanGrid.initVAO(gl);
skybox = new Skybox();
skybox.initVAO(gl);
// Initialize both cameras
orbitalCamera = new OrbitalCamera();
fpsCamera = new FPSCamera();
camera = orbitalCamera; // Start with Orbital camera
console.log('Cameras initialized - Press C to toggle between FPS and Orbital modes');
//Check if any errors apeared during init.
if (gl.getError() != gl.NO_ERROR) {
console.log("OpenGL Error!: ");
}
drawScene();
}
main();

View File

@@ -1,317 +0,0 @@
// WGSL Shaders for WebGPU
// Vertex shader for noise generation (fullscreen quad)
export const noiseVertexShader = `
@vertex
fn main(@builtin(vertex_index) vertexIndex: u32) -> @builtin(position) vec4<f32> {
var pos = array<vec2<f32>, 6>(
vec2<f32>(-1.0, -1.0),
vec2<f32>(1.0, -1.0),
vec2<f32>(-1.0, 1.0),
vec2<f32>(1.0, -1.0),
vec2<f32>(1.0, 1.0),
vec2<f32>(-1.0, 1.0)
);
return vec4<f32>(pos[vertexIndex], 0.0, 1.0);
}
`;
// Fragment shader for Perlin noise
export const noiseFragmentShader = `
@group(0) @binding(0) var<uniform> uTime: f32;
fn permute(x: vec4<f32>) -> vec4<f32> {
return ((x * 34.0 + 1.0) * x) % vec4<f32>(289.0);
}
fn taylorInvSqrt(r: vec4<f32>) -> vec4<f32> {
return 1.79284291400159 - 0.85373472095314 * r;
}
fn fade(t: vec3<f32>) -> vec3<f32> {
return t * t * t * (t * (t * 6.0 - 15.0) + 10.0);
}
fn cnoise(P: vec3<f32>) -> f32 {
var Pi0: vec3<f32> = floor(P);
var Pi1: vec3<f32> = Pi0 + vec3<f32>(1.0);
Pi0 = Pi0 % vec3<f32>(289.0);
Pi1 = Pi1 % vec3<f32>(289.0);
let Pf0 = fract(P);
let Pf1 = Pf0 - vec3<f32>(1.0);
let ix = vec4<f32>(Pi0.x, Pi1.x, Pi0.x, Pi1.x);
let iy = vec4<f32>(Pi0.yy, Pi1.yy);
let iz0 = Pi0.zzzz;
let iz1 = Pi1.zzzz;
let ixy = permute(permute(ix) + iy);
let ixy0 = permute(ixy + iz0);
let ixy1 = permute(ixy + iz1);
var gx0: vec4<f32> = ixy0 / 7.0;
var gy0: vec4<f32> = fract(floor(gx0) / 7.0) - 0.5;
gx0 = fract(gx0);
let gz0 = vec4<f32>(0.5) - abs(gx0) - abs(gy0);
let sz0 = step(gz0, vec4<f32>(0.0));
gx0 = gx0 - sz0 * (step(vec4<f32>(0.0), gx0) - 0.5);
gy0 = gy0 - sz0 * (step(vec4<f32>(0.0), gy0) - 0.5);
var gx1: vec4<f32> = ixy1 / 7.0;
var gy1: vec4<f32> = fract(floor(gx1) / 7.0) - 0.5;
gx1 = fract(gx1);
let gz1 = vec4<f32>(0.5) - abs(gx1) - abs(gy1);
let sz1 = step(gz1, vec4<f32>(0.0));
gx1 = gx1 - sz1 * (step(vec4<f32>(0.0), gx1) - 0.5);
gy1 = gy1 - sz1 * (step(vec4<f32>(0.0), gy1) - 0.5);
var g000: vec3<f32> = vec3<f32>(gx0.x, gy0.x, gz0.x);
var g100: vec3<f32> = vec3<f32>(gx0.y, gy0.y, gz0.y);
var g010: vec3<f32> = vec3<f32>(gx0.z, gy0.z, gz0.z);
var g110: vec3<f32> = vec3<f32>(gx0.w, gy0.w, gz0.w);
var g001: vec3<f32> = vec3<f32>(gx1.x, gy1.x, gz1.x);
var g101: vec3<f32> = vec3<f32>(gx1.y, gy1.y, gz1.y);
var g011: vec3<f32> = vec3<f32>(gx1.z, gy1.z, gz1.z);
var g111: vec3<f32> = vec3<f32>(gx1.w, gy1.w, gz1.w);
let norm0 = taylorInvSqrt(vec4<f32>(dot(g000, g000), dot(g100, g100), dot(g010, g010), dot(g110, g110)));
g000 = g000 * norm0.x;
g100 = g100 * norm0.y;
g010 = g010 * norm0.z;
g110 = g110 * norm0.w;
let norm1 = taylorInvSqrt(vec4<f32>(dot(g001, g001), dot(g101, g101), dot(g011, g011), dot(g111, g111)));
g001 = g001 * norm1.x;
g101 = g101 * norm1.y;
g011 = g011 * norm1.z;
g111 = g111 * norm1.w;
let n000 = dot(g000, Pf0);
let n100 = dot(g100, vec3<f32>(Pf1.x, Pf0.yz));
let n010 = dot(g010, vec3<f32>(Pf0.x, Pf1.y, Pf0.z));
let n110 = dot(g110, vec3<f32>(Pf1.xy, Pf0.z));
let n001 = dot(g001, vec3<f32>(Pf0.xy, Pf1.z));
let n101 = dot(g101, vec3<f32>(Pf1.x, Pf0.y, Pf1.z));
let n011 = dot(g011, vec3<f32>(Pf0.x, Pf1.yz));
let n111 = dot(g111, Pf1);
let fade_xyz = fade(Pf0);
let n_z = mix(vec4<f32>(n000, n100, n010, n110), vec4<f32>(n001, n101, n011, n111), fade_xyz.z);
let n_yz = mix(n_z.xy, n_z.zw, fade_xyz.y);
let n_xyz = mix(n_yz.x, n_yz.y, fade_xyz.x);
return 2.2 * n_xyz;
}
@fragment
fn main(@builtin(position) fragCoord: vec4<f32>) -> @location(0) vec4<f32> {
let resolution = vec2<f32>(256.0, 256.0);
let uv = fragCoord.xy / resolution;
var sum = 0.0;
var scale = 1.0;
var amplitude = 0.5;
for (var i = 0; i < 5; i = i + 1) {
// Make noise tileable by wrapping coordinates
let wrapped_uv = fract(uv * scale);
let p = vec3<f32>(wrapped_uv * 10.0, uTime * 0.2);
sum += cnoise(p) * amplitude;
scale *= 2.0;
amplitude *= 0.5;
}
return vec4<f32>(sum, 0.0, 0.0, 1.0);
}
`;
// Ocean vertex shader
export const oceanVertexShader = `
struct Uniforms {
view: mat4x4<f32>,
model: mat4x4<f32>,
projection: mat4x4<f32>,
eyePos: vec3<f32>,
};
@group(0) @binding(0) var<uniform> uniforms: Uniforms;
@group(0) @binding(1) var displacementTexture: texture_2d<f32>;
@group(0) @binding(2) var displacementSampler: sampler;
struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(0) fragPos: vec3<f32>,
@location(1) uv: vec2<f32>,
};
@vertex
fn main(
@location(0) position: vec3<f32>,
@location(1) uv: vec2<f32>
) -> VertexOutput {
var output: VertexOutput;
var worldPos = uniforms.model * vec4<f32>(position, 1.0);
output.uv = uv;
// Sample displacement using textureSampleLevel (works in vertex shader)
let displace = textureSampleLevel(displacementTexture, displacementSampler, uv, 0.0);
worldPos.z = worldPos.z + displace.r * 0.15;
output.position = uniforms.projection * uniforms.view * worldPos;
output.fragPos = worldPos.xyz;
return output;
}
`;
// Ocean fragment shader
export const oceanFragmentShader = `
struct Uniforms {
view: mat4x4<f32>,
model: mat4x4<f32>,
projection: mat4x4<f32>,
eyePos: vec3<f32>,
};
@group(0) @binding(0) var<uniform> uniforms: Uniforms;
@group(0) @binding(1) var displacementTexture: texture_2d<f32>;
@group(0) @binding(2) var displacementSampler: sampler;
@fragment
fn main(
@location(0) fragPos: vec3<f32>,
@location(1) uv: vec2<f32>
) -> @location(0) vec4<f32> {
// Sample displacement for normal calculation only
let gridPointDelta = 1.0 / 256.0;
let displacementScale = 0.15;
let displace = textureSample(displacementTexture, displacementSampler, uv).r * displacementScale;
let right = textureSample(displacementTexture, displacementSampler, vec2<f32>(uv.x + gridPointDelta, uv.y)).r * displacementScale;
let left = textureSample(displacementTexture, displacementSampler, vec2<f32>(uv.x - gridPointDelta, uv.y)).r * displacementScale;
let up = textureSample(displacementTexture, displacementSampler, vec2<f32>(uv.x, uv.y + gridPointDelta)).r * displacementScale;
let down = textureSample(displacementTexture, displacementSampler, vec2<f32>(uv.x, uv.y - gridPointDelta)).r * displacementScale;
// Calculate surface normal
let dX = vec3<f32>(gridPointDelta * 2.0, 0.0, right - left);
let dY = vec3<f32>(0.0, gridPointDelta * 2.0, up - down);
var norm = normalize(cross(dX, dY));
// Lighting
let lightDir = normalize(vec3<f32>(0.3, 0.5, 0.8));
let diff = max(dot(norm, lightDir), 0.0);
let diffuse = diff * vec3<f32>(0.8, 0.9, 1.0);
// Fresnel
let toCameraVector = normalize(fragPos - uniforms.eyePos);
let reflec = normalize(reflect(toCameraVector, norm));
let n1 = 1.0;
let n2 = 1.33333;
let R0 = pow((n1 - n2) / (n1 + n2), 2.0);
let fresnel = R0 + (1.0 - R0) * pow((1.0 - dot(norm, reflec)), 5.0);
let oceanColor = vec3<f32>(0.0, 0.25, 0.35);
let skyColor = vec3<f32>(0.4, 0.6, 0.8);
// Subsurface scattering
let sssSun = vec3<f32>(0.0, -5.0, -7.0);
let tosssSunVec = normalize(sssSun - fragPos);
let tosssSun = normalize(vec3<f32>(0.0, -100.0, 1.0));
let ssDistortion = 0.1;
let sssIntensity = 1.0;
let halfWay = normalize(tosssSun + norm * ssDistortion);
let ssScateringCoef = pow(clamp(dot(toCameraVector, -halfWay), 0.0, 1.0), 5.0) * sssIntensity;
// Sun glittering
var glitterFactor = max(0.0, dot(tosssSunVec, reflect(-toCameraVector, norm)));
if (glitterFactor <= 0.98) {
glitterFactor = 0.0;
}
let lightColor = vec3<f32>(1.0, 1.0, 1.0);
let ambientColor = vec3<f32>(0.1, 0.15, 0.2);
let finalColor = ambientColor +
diffuse * 0.4 +
mix(oceanColor * (1.0 + ssScateringCoef), skyColor * 0.5, fresnel * 0.7) +
lightColor * glitterFactor * 0.5;
return vec4<f32>(clamp(finalColor, vec3<f32>(0.0), vec3<f32>(1.0)), 1.0);
}
`;
// Skybox vertex shader
export const skyboxVertexShader = `
struct Uniforms {
view: mat4x4<f32>,
projection: mat4x4<f32>,
sunDirection: vec3<f32>,
};
@group(0) @binding(0) var<uniform> uniforms: Uniforms;
struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(0) rayDir: vec3<f32>,
};
@vertex
fn main(@location(0) position: vec3<f32>) -> VertexOutput {
var output: VertexOutput;
output.rayDir = position;
// Remove translation from view matrix
var rotView = uniforms.view;
rotView[3] = vec4<f32>(0.0, 0.0, 0.0, 1.0);
let pos = uniforms.projection * rotView * vec4<f32>(position, 1.0);
output.position = pos;
return output;
}
`;
// Skybox fragment shader
export const skyboxFragmentShader = `
struct Uniforms {
view: mat4x4<f32>,
projection: mat4x4<f32>,
sunDirection: vec3<f32>,
};
@group(0) @binding(0) var<uniform> uniforms: Uniforms;
@fragment
fn main(@location(0) rayDir: vec3<f32>) -> @location(0) vec4<f32> {
let ray = normalize(rayDir);
let upAmount = ray.z;
// Sky gradient
let horizonBlend = pow(1.0 - max(upAmount, 0.0), 2.0);
let zenithColor = vec3<f32>(0.15, 0.35, 0.75);
let horizonColor = vec3<f32>(0.55, 0.7, 0.9);
var skyColor = mix(zenithColor, horizonColor, horizonBlend);
// Horizon glow
let horizonGlow = pow(max(1.0 - abs(upAmount), 0.0), 6.0);
skyColor = skyColor + vec3<f32>(0.4, 0.25, 0.1) * horizonGlow * 0.4;
// Sun
let sunDir = normalize(uniforms.sunDirection);
let sunAngle = max(dot(ray, sunDir), 0.0);
let sunDisk = smoothstep(0.9993, 0.9998, sunAngle);
let sunColor = vec3<f32>(1.0, 0.95, 0.85);
let sunGlow = pow(sunAngle, 48.0) * 0.6;
let sunHalo = pow(sunAngle, 6.0) * 0.25;
skyColor = skyColor + sunColor * sunDisk * 3.0;
skyColor = skyColor + vec3<f32>(1.0, 0.85, 0.5) * sunGlow;
skyColor = skyColor + vec3<f32>(1.0, 0.9, 0.7) * sunHalo;
// Below horizon
if (upAmount < 0.0) {
let depth = -upAmount;
let deepColor = vec3<f32>(0.02, 0.08, 0.15);
skyColor = mix(horizonColor * 0.7, deepColor, smoothstep(0.0, 0.5, depth));
}
return vec4<f32>(skyColor, 1.0);
}
`;

View File

@@ -3,7 +3,6 @@
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"types": ["@webgpu/types"],
"sourceMap": true,
"outDir": "./build/",
"strict": true,
@@ -15,5 +14,5 @@
"skipLibCheck": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "build", "src/main_webgl.ts"]
"exclude": ["node_modules", "dist", "build"]
}