7 Commits

17 changed files with 1178 additions and 2231 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> <!DOCTYPE html>
<html> <html>
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -97,17 +96,54 @@
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
} }
#frame-time { .slider-group {
position: absolute; margin: 8px 0;
bottom: 20px; }
left: 100px;
background: rgba(0, 0, 0, 0.7); .slider-group label {
color: #0ff; display: flex;
padding: 8px 12px; justify-content: space-between;
border-radius: 5px; align-items: center;
font-family: 'Courier New', monospace; margin-bottom: 4px;
font-size: 14px; font-size: 13px;
backdrop-filter: blur(10px); }
.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> </style>
<script id="noise-fs" type="x-shader/x-fragment"> <script id="noise-fs" type="x-shader/x-fragment">
@@ -204,96 +240,305 @@
precision mediump float; precision mediump float;
varying vec3 v_fragPos; 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 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. // Simple hash function for noise
vec3 lightColor = vec3(1.0,1.0,1.0); float hash(vec2 p) {
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
}
//using forward difference // Value noise for foam texture
//Normal vectors are compute as: https://www.scratchapixel.com/lessons/procedural-generation-virtual-worlds/perlin-noise-part-2/perlin-noise-computing-derivatives 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) { void main(void) {
vec4 displace = texture2D(displace_map, v_uv); vec3 lightColor = vec3(1.0, 1.0, 0.95);
//calculate normal vec3 sunDirection = normalize(vec3(0.3, 0.5, 0.8));
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 norm = normalize(v_normal);
//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); // View direction
norm.y *= -1.; //Normal y direction is somehow inverted vec3 viewDir = normalize(eyePos - v_fragPos);
//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); // Diffuse lighting
float diff = max(dot(norm, sunDirection), 0.0);
vec3 diffuse = diff * lightColor; vec3 diffuse = diff * lightColor;
vec3 result = (diffuse) * vec3(0.0,0.0,1.0);
//Old lightning // Schlick's approximation to Fresnel factor
vec3 toCameraVector = normalize(v_fragPos - eyePos); float R0 = 0.02;
vec3 reflec = normalize(reflect(toCameraVector, norm)); float fresnel = R0 + (1.0 - R0) * pow(1.0 - max(dot(norm, viewDir), 0.0), 5.0);
//Schlicks approximation to Fresnelfactor // Deep and shallow water colors
float n1 = 1., n2 = 1.33333; vec3 deepColor = vec3(0.0, 0.08, 0.15);
float R0 = pow((n1-n2)/(n1+n2), 2.); vec3 shallowColor = vec3(0.0, 0.35, 0.45);
float fresnel = R0 + (1. - R0)*pow((1.-dot(norm,reflec)),5.) ; vec3 skyColor = vec3(0.55, 0.7, 0.9); // Match skybox horizon color
vec3 foamColor = vec3(0.95, 0.98, 1.0);
//vec3 waterColor = vec3(34./255.,154./255.,211./255.); // Blend between deep and shallow based on wave height
vec3 oceanColor = vec3(0,.4,.4); // under-sea colour float heightFactor = clamp(v_waveHeight * 2.0 + 0.5, 0.0, 1.0);
vec3 skyColor = vec3(1.,1.,1.); vec3 oceanColor = mix(deepColor, shallowColor, heightFactor);
//Subsurface scattering // Sun glitter - uses wave normals for natural sparkle from fine surface detail
vec3 sssSun = vec3(0.,-5.,-7.0); vec3 reflectDir = reflect(-sunDirection, norm);
vec3 tosssSunVec = normalize(sssSun - v_fragPos); float specAngle = max(dot(viewDir, reflectDir), 0.0);
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;
}
//gl_FragColor = vec4(oceanColor + lightColor * glitterFactor,1.0); // Smooth base specular
//gl_FragColor=vec4(clamp(oceanColor + (oceanColor*ssScateringCoef),0.,1.0),1.0); //Display subsurfacecatterting component float specBase = pow(specAngle, 64.0) * 0.4;
//gl_FragColor = vec4((mix(oceanColor,skyColor,fresnel).xyz), 1.); //Just display reflection component // Medium highlights
//gl_FragColor = vec4(diffuse * oceanColor,1.0); //Render only diffuse component float specMid = pow(specAngle, 256.0) * 1.2;
//gl_FragColor = vec4(normal,1.0); //show Normal map // Sharp glitter peaks
//gl_FragColor = vec4(displace.x,displace.x,displace.x,1.0); //Show Perlin Noise texture deactivate vertex distrotion before float specSharp = pow(specAngle, 1024.0) * 3.0;
gl_FragColor = vec4((clamp(diffuse,0.97,1.0) * (mix(oceanColor + (oceanColor*ssScateringCoef),skyColor*0.8,fresnel).xyz))+ lightColor * glitterFactor, 1.0); //All combined
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>
<script id="default-vs" type="x-shader/x-vertex"> <script id="default-vs" type="x-shader/x-vertex">
precision mediump float;
attribute vec3 positionAttr; attribute vec3 positionAttr;
uniform mat4 view; uniform mat4 view;
uniform mat4 model; uniform mat4 model;
uniform mat4 projection; uniform mat4 projection;
uniform sampler2D displace_map; uniform float uTime;
uniform float uWaveHeight;
uniform float uWaveSpeed;
uniform vec3 eyePos;
varying vec2 v_uv;
varying vec3 v_fragPos; 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)
);
}
void main(void) { void main(void) {
vec4 displace = texture2D(displace_map, vec2(positionAttr.x,positionAttr.y)); vec4 worldPos = model * vec4(positionAttr.xyz, 1.0);
vec4 worldPos = model * vec4(positionAttr.x,positionAttr.y,positionAttr.z + displace.x, 1.0); // Grid is on XY plane, Z is up
gl_Position = projection * view * worldPos; 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);
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);
// Horizon projection: calculate where the world horizon would be in clip space
// The horizon is where z=0 plane meets the sky (at eye height)
// Project a point at the horizon in the same XY direction as this vertex
float horizonStretch = smoothstep(40.0, 100.0, distToCamera);
if (horizonStretch > 0.0) {
// Get direction from camera to vertex (XY only, on ocean plane)
vec2 toVertex = normalize(worldPos.xy - eyePos.xy);
// Create a horizon point far away in that direction at z=0
vec3 horizonPoint = vec3(
eyePos.xy + toVertex * 10000.0,
0.0
);
// Project horizon point to get true horizon clip position
vec4 horizonClip = projection * view * vec4(horizonPoint, 1.0);
// Get actual clip position
vec4 clipPos = projection * view * worldPos;
// Blend vertex toward the horizon point's clip position (normalized)
// Overshoot slightly past horizon to ensure no gap
float horizonY = horizonClip.y / horizonClip.w * clipPos.w;
float overshoot = 1.0 + horizonStretch * 0.1; // Push slightly past horizon
clipPos.y = mix(clipPos.y, horizonY * overshoot, horizonStretch);
gl_Position = clipPos;
} else {
gl_Position = projection * view * worldPos;
}
v_fragPos = worldPos.xyz; v_fragPos = worldPos.xyz;
v_uv = positionAttr.xy; }
</script>
} }
</script> </script>
<script id="sky-fs" type="x-shader/x-fragment"> <script id="sky-fs" type="x-shader/x-fragment">
@@ -362,7 +607,6 @@
gl_Position = pos; gl_Position = pos;
} }
</script> </script>
</head> </head>
<body> <body>
@@ -371,25 +615,15 @@
<div id="controls"> <div id="controls">
<h3>🌊 Ocean Controls</h3> <h3>🌊 Ocean Controls</h3>
<div class="control-group"> <div class="control-group">
<strong>Camera Mode:</strong> <span class="key">C</span> (FPS/Orbital)<br> <strong>Camera Rotation:</strong><br>
<span id="current-camera-mode" style="font-size: 12px; color: #aaa;">Current: Orbital</span> <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>
<div class="control-group"> <div class="control-group">
<strong>Rendering:</strong><br> <strong>Zoom:</strong><br>
<span class="key">F</span> Toggle Wireframe <span class="key">Q</span> / <span class="key">E</span> or <span class="key">+</span> / <span class="key">-</span>
</div> </div>
<div class="control-group"> <div class="control-group">
<strong>FPS Camera:</strong><br> <strong>Mouse:</strong> Click and drag to rotate
<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
</div> </div>
<div class="control-group"> <div class="control-group">
<strong>Reset:</strong> <span class="key">R</span> <strong>Reset:</strong> <span class="key">R</span>
@@ -397,19 +631,42 @@
<div class="control-group"> <div class="control-group">
<strong>Toggle Help:</strong> <span class="key">H</span> <strong>Toggle Help:</strong> <span class="key">H</span>
</div> </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> </div>
<button id="toggle-controls">Toggle Controls (H)</button> <button id="toggle-controls">Toggle Controls (H)</button>
<div id="fps-counter">FPS: 0</div> <div id="fps-counter">FPS: 0</div>
<div id="frame-time">Frame: 0.00ms</div>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
<script> <script>
// Toggle controls visibility // Toggle controls visibility
const controls = document.getElementById('controls'); const controls = document.getElementById('controls');
const toggleBtn = document.getElementById('toggle-controls'); const toggleBtn = document.getElementById('toggle-controls');
const cameraModeDisplay = document.getElementById('current-camera-mode');
toggleBtn.addEventListener('click', () => { toggleBtn.addEventListener('click', () => {
controls.classList.toggle('hidden'); controls.classList.toggle('hidden');
@@ -419,28 +676,29 @@
if (evt.key === 'h' || evt.key === 'H') { if (evt.key === 'h' || evt.key === 'H') {
controls.classList.toggle('hidden'); 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 // Wireframe toggle
window.addEventListener('toggleCameraMode', () => { const wireframeBtn = document.getElementById('wireframe-toggle');
const cameraMode = document.getElementById('camera-mode'); wireframeBtn.addEventListener('click', () => {
if (cameraMode && cameraModeDisplay) { window.dispatchEvent(new CustomEvent('toggleWireframe'));
const mode = cameraMode.textContent.replace('Camera: ', '');
cameraModeDisplay.textContent = `Current: ${mode}`;
}
}); });
// 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> </script>
</body> </body>

8
package-lock.json generated
View File

@@ -12,7 +12,6 @@
"gl-matrix": "^3.4.4" "gl-matrix": "^3.4.4"
}, },
"devDependencies": { "devDependencies": {
"@webgpu/types": "^0.1.69",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^6.0.7" "vite": "^6.0.7"
} }
@@ -816,13 +815,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/esbuild": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",

View File

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

View File

@@ -1,74 +1,38 @@
# 🌊 WebOcean # 🌊 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) ![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) ![Vite](https://img.shields.io/badge/Vite-6.0-646CFF?style=flat-square)
## ✨ Features ## ✨ Features
- **Real-time Ocean Simulation** - Dynamic water surface with Perlin noise-based displacement - **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**: - **Advanced Rendering Techniques**:
- Fresnel reflection for realistic water appearance - Fresnel reflection for realistic water appearance
- Subsurface scattering for light penetration - Subsurface scattering for light penetration
- Specular highlights for sun glitter effect - Specular highlights for sun glitter effect
- Dynamic normal mapping from displacement - Dynamic normal mapping from displacement
- Gradient skybox with sun rendering - **Interactive Camera Controls** - Mouse and keyboard navigation
- **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
- **Responsive Design** - Automatically adapts to window size - **Responsive Design** - Automatically adapts to window size
- **Performance Monitoring** - Real-time FPS counter and frame time - **Performance Monitoring** - Real-time FPS counter
## 🎮 Controls ## 🎮 Controls
### Camera Controls
| Action | Keys | | Action | Keys |
|--------|------| |--------|------|
| **Toggle Camera Mode** | `C` | | **Rotate Camera** | `W` `A` `S` `D` or Arrow Keys |
| **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 |
| **Zoom In/Out** | `Q` / `E` or `+` / `-` | | **Zoom In/Out** | `Q` / `E` or `+` / `-` |
| **Mouse Drag** | Click and drag to rotate |
### FPS Camera Mode | **Reset Camera** | `R` |
| **Toggle Help** | `H` |
| 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` |
## 🚀 Getting Started ## 🚀 Getting Started
### Prerequisites ### Prerequisites
- **Node.js** (v16 or higher) - Node.js (v16 or higher)
- **Browser**: Chrome 113+, Edge 113+, or Firefox 130+ (with WebGPU enabled)
- npm or yarn - npm or yarn
### Installation ### Installation

View File

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

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

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;
}

200
src/OceanLOD.ts Normal file
View File

@@ -0,0 +1,200 @@
import { Grid } from './Grid';
import { vec3 } from 'gl-matrix';
/** Manages multiple ocean grid patches with LOD based on camera distance and view cone */
export class OceanLOD {
private grids: Array<{
grid: Grid;
centerX: number;
centerY: number;
size: number;
lodLevel: number;
visible: boolean;
}> = [];
private readonly LOD_LEVELS = [
{ distance: 3.0, gridSize: 256 }, // Very close - ultra detail
{ distance: 8.0, gridSize: 128 }, // Close - high detail
{ distance: 20.0, gridSize: 64 }, // Medium distance
{ distance: 40.0, gridSize: 16 }, // Far - low detail
{ distance: 80.0, gridSize: 8 }, // Very far - minimal
{ distance: Infinity, gridSize: 4 },// Horizon - lowest (will be stretched anyway)
];
private readonly PATCH_SIZE = 10.0; // Larger patches = fewer needed
private readonly PATCHES_PER_SIDE = 21; // 21x21 = 441 patches (covers ~200 units)
private readonly VIEW_CONE_COS = Math.cos(Math.PI * 0.45); // ~81 degree half-angle (wider than typical FOV)
// Track the grid origin to re-center when camera moves
private gridOriginX: number = 0;
private gridOriginY: number = 0;
constructor() {
this.createGridPatches();
}
private createGridPatches(): void {
const halfPatches = Math.floor(this.PATCHES_PER_SIDE / 2);
for (let y = -halfPatches; y <= halfPatches; y++) {
for (let x = -halfPatches; x <= halfPatches; x++) {
const centerX = x * this.PATCH_SIZE + this.gridOriginX;
const centerY = y * this.PATCH_SIZE + this.gridOriginY;
// Start with lowest detail - will be updated based on camera
const grid = new Grid(
this.LOD_LEVELS[5].gridSize,
centerX,
centerY,
this.PATCH_SIZE
);
this.grids.push({
grid,
centerX,
centerY,
size: this.PATCH_SIZE,
lodLevel: 5,
visible: true
});
}
}
}
/** Re-center the grid around a new origin */
private recenterGrid(gl: WebGL2RenderingContext, newOriginX: number, newOriginY: number): void {
this.gridOriginX = newOriginX;
this.gridOriginY = newOriginY;
const halfPatches = Math.floor(this.PATCHES_PER_SIDE / 2);
let i = 0;
for (let y = -halfPatches; y <= halfPatches; y++) {
for (let x = -halfPatches; x <= halfPatches; x++) {
const patch = this.grids[i];
const newCenterX = x * this.PATCH_SIZE + this.gridOriginX;
const newCenterY = y * this.PATCH_SIZE + this.gridOriginY;
// Only update if patch position changed
if (patch.centerX !== newCenterX || patch.centerY !== newCenterY) {
patch.centerX = newCenterX;
patch.centerY = newCenterY;
// Force LOD recalculation
patch.lodLevel = -1;
}
i++;
}
}
}
/** Update LOD based on camera position and view direction */
updateLOD(gl: WebGL2RenderingContext, cameraPos: vec3, cameraTarget: vec3): void {
// Calculate view direction (normalized)
const viewDir = vec3.create();
vec3.subtract(viewDir, cameraTarget, cameraPos);
vec3.normalize(viewDir, viewDir);
// Check if we need to recenter the grid (camera moved more than one patch size from origin)
const cameraGridX = Math.floor(cameraPos[0] / this.PATCH_SIZE) * this.PATCH_SIZE;
const cameraGridY = Math.floor(cameraPos[1] / this.PATCH_SIZE) * this.PATCH_SIZE;
if (cameraGridX !== this.gridOriginX || cameraGridY !== this.gridOriginY) {
this.recenterGrid(gl, cameraGridX, cameraGridY);
}
for (const patch of this.grids) {
// Calculate vector from camera to patch center (on XY plane, Z=0 for ocean surface)
const toPatch = vec3.fromValues(
patch.centerX - cameraPos[0],
patch.centerY - cameraPos[1],
0 - cameraPos[2] // Ocean is at Z=0
);
const distance = vec3.length(toPatch);
// Normalize direction to patch
const toPatchDir = vec3.create();
vec3.normalize(toPatchDir, toPatch);
// Calculate dot product with view direction (how aligned is patch with where we're looking)
const dotProduct = vec3.dot(viewDir, toPatchDir);
// Determine if patch is in front of camera and within view cone
const isInFront = dotProduct > -0.3; // Slightly behind is ok for edge cases
const isInViewCone = dotProduct > this.VIEW_CONE_COS;
// Frustum culling - don't draw patches behind camera
patch.visible = isInFront;
// Calculate LOD level
let newLodLevel = 5; // Default to lowest detail
if (!isInFront) {
// Behind camera - skip (will not be drawn)
newLodLevel = 5;
} else if (isInViewCone) {
// In view cone - use distance-based LOD
for (let i = 0; i < this.LOD_LEVELS.length; i++) {
if (distance < this.LOD_LEVELS[i].distance) {
newLodLevel = i;
break;
}
}
} else {
// In front but outside view cone - reduce detail by 1-2 levels
for (let i = 0; i < this.LOD_LEVELS.length; i++) {
if (distance < this.LOD_LEVELS[i].distance) {
newLodLevel = Math.min(i + 2, 5); // Reduce detail
break;
}
}
}
// Only recreate grid if LOD level changed
if (newLodLevel !== patch.lodLevel) {
patch.lodLevel = newLodLevel;
patch.grid = new Grid(
this.LOD_LEVELS[newLodLevel].gridSize,
patch.centerX,
patch.centerY,
patch.size
);
patch.grid.initVAO(gl);
}
}
}
initVAO(gl: WebGL2RenderingContext): void {
for (const { grid } of this.grids) {
grid.initVAO(gl);
}
}
draw(gl: WebGL2RenderingContext, wireframe: boolean = false): void {
for (const patch of this.grids) {
if (patch.visible) {
patch.grid.draw(gl, wireframe);
}
}
}
getGridCount(): number {
return this.grids.length;
}
getTotalVertexCount(): number {
let total = 0;
for (const { grid } of this.grids) {
total += grid.getIndexCount() / 3;
}
return total;
}
/** Get statistics about current LOD distribution */
getLODStats(): { [key: number]: number } {
const stats: { [key: number]: number } = { 0: 0, 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 };
for (const patch of this.grids) {
stats[patch.lodLevel]++;
}
return stats;
}
}

View File

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

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 // Configuration Constants
export const GRID_SIZE = 128; export const GRID_SIZE = 128;
export const NOISE_TEXTURE_WIDTH = 256; export const NOISE_TEXTURE_WIDTH = 1024;
export const NOISE_TEXTURE_HEIGHT = 256; export const NOISE_TEXTURE_HEIGHT = 1024;
export const CANVAS_WIDTH = 800; export const CANVAS_WIDTH = 800;
export const CANVAS_HEIGHT = 600; export const CANVAS_HEIGHT = 600;
export const FOV = 1.0; export const FOV = 1.0;

View File

@@ -1,85 +1,20 @@
import { vec3, mat4 } from 'gl-matrix'; import { vec3, mat4 } from 'gl-matrix';
import { ICamera } from './ICamera'; import { Camera } from './Camera';
import { OrbitalCamera } from './Camera'; import { OceanLOD } from './OceanLOD';
import { FPSCamera } from './FPSCamera';
import { Grid } from './Grid';
import { Skybox } from './Skybox'; import { Skybox } from './Skybox';
import { WebGPUContext } from './WebGPUContext'; import { createProgram } from './Shader';
import {
noiseVertexShader, noiseFragmentShader,
oceanVertexShader, oceanFragmentShader,
skyboxVertexShader, skyboxFragmentShader
} from './shaders.wgsl';
import * as Config from './constants'; import * as Config from './constants';
// Global state var gl: WebGL2RenderingContext;
let gpuContext: WebGPUContext; var viewportWidth = 0;
let viewportWidth = 0; var viewportHeight = 0;
let viewportHeight = 0;
// Render pipelines /** A camera that always looks at the world origin. Can have an offset and be rotated. */
let noisePipeline: GPURenderPipeline; // Moved to Camera.ts
let oceanPipeline: GPURenderPipeline;
let skyboxPipeline: GPURenderPipeline;
// Textures and buffers /** Init OpenGL and gets the viewport/canvas sizes */
let noiseTexture: GPUTexture; function initGL(canvas: HTMLCanvasElement) {
let noiseTextureView: GPUTextureView; // Helper function for canvas resize
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> {
const updateCanvasSize = (canvas: HTMLCanvasElement) => { const updateCanvasSize = (canvas: HTMLCanvasElement) => {
const displayWidth = window.innerWidth; const displayWidth = window.innerWidth;
const displayHeight = window.innerHeight; const displayHeight = window.innerHeight;
@@ -90,590 +25,376 @@ async function initWebGPU(canvas: HTMLCanvasElement): Promise<(canvas: HTMLCanva
viewportWidth = displayWidth; viewportWidth = displayWidth;
viewportHeight = displayHeight; viewportHeight = displayHeight;
// Recreate depth texture on resize if (gl) {
if (gpuContext && depthTexture) { gl.viewport(0, 0, viewportWidth, viewportHeight);
depthTexture.destroy();
createDepthTexture();
} }
} }
}; };
updateCanvasSize(canvas); var gltemp;
try {
gltemp = canvas.getContext("webgl2");
if (!gltemp)
gltemp = canvas.getContext("experimental-webgl2");
if (gltemp != null) {
updateCanvasSize(canvas);
}
gpuContext = new WebGPUContext(canvas); } catch (e) {
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");
} }
// 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; return updateCanvasSize;
} }
/** Create depth texture for depth testing */ /** Update canvas size to fill window */
function createDepthTexture() { // Moved inline below
const device = gpuContext.getDevice();
depthTexture = device.createTexture({ /** Grid for the watersurface */
size: { width: viewportWidth, height: viewportHeight }, // Moved to Grid.ts
format: 'depth24plus',
usage: GPUTextureUsage.RENDER_ATTACHMENT, /** Init Geometry for a Triangle */
}); var VBO: WebGLBuffer | null = null;
depthTextureView = depthTexture.createView(); 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 */ /** Get shader source by HTML-Element<id> */
function createNoiseTexture() { // Moved to Shader.ts
const device = gpuContext.getDevice();
noiseTexture = device.createTexture({ /** Init all Shaders that are needed */
size: { var perlinNoiseProgram: WebGLProgram | null;
width: Config.NOISE_TEXTURE_WIDTH, var defaultProgram: WebGLProgram | null;
height: Config.NOISE_TEXTURE_HEIGHT var textureProgram: WebGLProgram | null;
}, var skyProgram: WebGLProgram | null;
format: 'rgba16float', function initShaders() {
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING, perlinNoiseProgram = createProgram(gl, "ndc-vs", "noise-fs", "Perlin Noise");
}); defaultProgram = createProgram(gl, "default-vs", "default-fs", "Default");
noiseTextureView = noiseTexture.createView(); textureProgram = createProgram(gl, "texture-vs", "texture-fs", "Texture");
skyProgram = createProgram(gl, "sky-vs", "sky-fs", "Sky");
} }
/** Create sampler for texture sampling */ /** Init an FBO used for the first render pass / perlin noise */
function createSampler() { var perlinNoiseFBO: WebGLFramebuffer | null = null;
const device = gpuContext.getDevice(); 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({ // Add attachments
addressModeU: 'repeat', textureFBO = gl.createTexture();
addressModeV: 'repeat', gl.bindTexture(gl.TEXTURE_2D, textureFBO); //last 3 parameter not intertesting becuase we are not supplying data
magFilter: 'linear', gl.texImage2D(gl.TEXTURE_2D, 0, gl.R16F, perlinNoiseFBOWidth, perlinNoiseFBOHeight, 0, gl.RED, gl.HALF_FLOAT, null);
minFilter: 'linear',
});
}
/** Create uniform buffers */ // set the filtering so we don't need mips
function createUniformBuffers() { gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
const device = gpuContext.getDevice(); 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);
// Noise uniform: time (f32, 4 bytes) - needs padding to 16 bytes gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, textureFBO, 0);
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 if (gl.checkFramebufferStatus(gl.FRAMEBUFFER) != gl.FRAMEBUFFER_COMPLETE) {
oceanUniformBuffer = device.createBuffer({ console.log("Framebuffer creation failed.");
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,
});
}
/** 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 */
function drawScene() {
if (isPaused) {
requestAnimationFrame(drawScene);
return;
} }
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;
var lodStatsTimer = 0;
/** Input states*/
var mouseXVel = 0;
var mouseYVel = 0;
var keysPressed: Set<string> = new Set();
/** Objects and states*/
var camera: Camera;
var oceanLOD: OceanLOD;
var skybox: Skybox;
var wireframeMode = false;
/** Camera movement speed */
var moveSpeed = 0.15;
var fastMoveSpeed = 0.4;
/** Ocean shader settings */
var waveHeight = 1.0;
var waveSpeed = 1.0;
var foamIntensity = 1.0;
var glitterIntensity = 1.0;
function drawScene() {
fps++; fps++;
const now = Date.now(); let now = new Date();
const delta = now - lastTime; let delta = now.getTime() - lastTime;
timeSpent += delta * animationSpeed / 1000.0; // Convert to seconds and apply speed timeSpent += delta;
lodStatsTimer += delta;
if ((counter += delta) >= Config.FPS_UPDATE_INTERVAL) { if ((counter += delta) >= Config.FPS_UPDATE_INTERVAL) {
counter = 0; counter = 0;
const frameTime = delta.toFixed(2);
if (fpsDisplay) { if (fpsDisplay) {
fpsDisplay.textContent = `FPS: ${fps}`; fpsDisplay.textContent = `FPS: ${fps}`;
} }
if (frameTimeDisplay) {
frameTimeDisplay.textContent = `Frame: ${frameTime}ms`;
}
fps = 0; fps = 0;
} }
lastTime = now;
const device = gpuContext.getDevice(); // Log LOD stats every 5 seconds
const queue = device.queue; if (lodStatsTimer >= 5000) {
const context = gpuContext.getContext(); lodStatsTimer = 0;
const stats = oceanLOD.getLODStats();
// --- First Pass: Generate Perlin Noise --- console.log(`LOD Stats - High:${stats[0]} Med:${stats[1]} Low:${stats[2]} VeryLow:${stats[3]}`);
{
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()]);
} }
lastTime = now.getTime();
// --- Second Pass: Render Scene (Skybox + Ocean) --- // Sun direction (matches the one in ocean shader)
const sunDirection = vec3.fromValues(0.3, 0.5, 0.8);
vec3.normalize(sunDirection, sunDirection);
//--- Render pass -> Skybox first (no depth write) ---
{ {
const projection = mat4.create(); 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); mat4.perspective(projection, Config.FOV, viewportWidth / viewportHeight, Config.NEAR_PLANE, Config.FAR_PLANE);
// Handle camera movement // Handle FPS camera movement
if (cameraMode === 'fps') { handleCameraMovement();
camera = fpsCamera;
handleFPSCameraMovement();
if (mouseXVel !== 0 || mouseYVel !== 0) { // Apply mouse rotation
fpsCamera.rotate(mouseXVel, mouseYVel); if (mouseXVel !== 0 || mouseYVel !== 0) {
mouseXVel = 0; camera.rotate(mouseXVel, mouseYVel);
mouseYVel = 0; 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(); var view = camera.getViewMatrix();
const sunDirection = vec3.fromValues(0.3, 0.5, 0.8);
vec3.normalize(sunDirection, sunDirection);
// Update skybox uniforms // Draw skybox first with depth test disabled (always behind everything)
{ gl.depthMask(false);
const skyboxData = new Float32Array(36); // 2 mat4 + vec3 + padding gl.disable(gl.DEPTH_TEST);
skyboxData.set(view, 0); gl.useProgram(skyProgram);
skyboxData.set(projection, 16);
skyboxData.set(sunDirection, 32);
queue.writeBuffer(skyboxUniformBuffer, 0, skyboxData);
}
// Update ocean uniforms let sky_view_loc = gl.getUniformLocation(<WebGLProgram>skyProgram, "view");
{ gl.uniformMatrix4fv(sky_view_loc, false, view);
const model = mat4.create(); let sky_projection_loc = gl.getUniformLocation(<WebGLProgram>skyProgram, "projection");
mat4.translate(model, model, vec3.fromValues(-0.5, -0.5, 0.0)); gl.uniformMatrix4fv(sky_projection_loc, false, projection);
let sky_sun_loc = gl.getUniformLocation(<WebGLProgram>skyProgram, "uSunDirection");
gl.uniform3fv(sky_sun_loc, sunDirection);
const oceanData = new Float32Array(52); // 3 mat4 + vec3 + padding skybox.draw(gl);
oceanData.set(view, 0); gl.enable(gl.DEPTH_TEST);
oceanData.set(model, 16); gl.depthMask(true);
oceanData.set(projection, 32);
oceanData.set(camera.pos, 48);
queue.writeBuffer(oceanUniformBuffer, 0, oceanData);
}
const commandEncoder = device.createCommandEncoder(); // Update LOD based on camera position and view direction
const textureView = context.getCurrentTexture().createView(); oceanLOD.updateLOD(gl, camera.pos, camera.target);
const renderPass = commandEncoder.beginRenderPass({ var model = mat4.create();
colorAttachments: [{ mat4.identity(model);
view: textureView, // No centering needed - grids are already positioned correctly in world space
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',
}
});
// Draw skybox gl.useProgram(defaultProgram);
renderPass.setPipeline(skyboxPipeline); let view_loc = gl.getUniformLocation(<WebGLProgram>defaultProgram, "view");
renderPass.setBindGroup(0, skyboxBindGroup); gl.uniformMatrix4fv(view_loc, false, view);
skybox.draw(renderPass); 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);
// Draw ocean // Ocean shader settings
renderPass.setPipeline(oceanPipeline); let uWaveHeight_loc = gl.getUniformLocation(<WebGLProgram>defaultProgram, "uWaveHeight");
renderPass.setBindGroup(0, oceanBindGroup); gl.uniform1f(uWaveHeight_loc, waveHeight);
oceanGrid.draw(renderPass, wireframeMode); 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);
renderPass.end(); oceanLOD.draw(gl, wireframeMode);
queue.submit([commandEncoder.finish()]);
} }
requestAnimationFrame(drawScene); requestAnimationFrame(drawScene);
} }
/** Handle FPS camera movement */ /** Handle FPS camera movement */
function handleFPSCameraMovement() { function handleCameraMovement() {
const speed = keysPressed.has('Shift') ? fastMoveSpeed : moveSpeed; const speed = keysPressed.has('Shift') ? fastMoveSpeed : moveSpeed;
// WASD for horizontal movement
if (keysPressed.has('w') || keysPressed.has('W')) { if (keysPressed.has('w') || keysPressed.has('W')) {
fpsCamera.moveForward(speed); camera.moveForward(speed);
} }
if (keysPressed.has('s') || keysPressed.has('S')) { if (keysPressed.has('s') || keysPressed.has('S')) {
fpsCamera.moveForward(-speed); camera.moveForward(-speed);
} }
if (keysPressed.has('a') || keysPressed.has('A')) { if (keysPressed.has('a') || keysPressed.has('A')) {
fpsCamera.moveRight(-speed); camera.moveRight(-speed);
} }
if (keysPressed.has('d') || keysPressed.has('D')) { 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')) { if (keysPressed.has('q') || keysPressed.has('Q')) {
fpsCamera.moveUp(-speed); camera.moveUp(-speed);
} }
if (keysPressed.has('e') || keysPressed.has('E')) { if (keysPressed.has('e') || keysPressed.has('E')) {
fpsCamera.moveUp(speed); camera.moveUp(speed);
} }
// Space to go up, Ctrl to go down
if (keysPressed.has(' ')) { if (keysPressed.has(' ')) {
fpsCamera.moveUp(speed); camera.moveUp(speed);
} }
if (keysPressed.has('Control')) { if (keysPressed.has('Control')) {
fpsCamera.moveUp(-speed); camera.moveUp(-speed);
} }
} }
/** Handle keyboard input for orbital camera */ function main() {
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() {
const canvas: HTMLCanvasElement = <HTMLCanvasElement>document.getElementById("window"); const canvas: HTMLCanvasElement = <HTMLCanvasElement>document.getElementById("window");
fpsDisplay = document.getElementById("fps-counter"); fpsDisplay = document.getElementById("fps-counter");
frameTimeDisplay = document.getElementById("frame-time");
try { const updateCanvasSize = initGL(canvas);
const updateCanvasSize = await initWebGPU(canvas); if (!updateCanvasSize) {
console.error("Failed to initialize WebGL");
// Create resources return;
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', () => {
drag = true;
});
canvas.addEventListener('mousemove', (evt) => {
if (drag) {
if (previousPosX == null || previousPosY == null) {
previousPosX = evt.x;
previousPosY = evt.y;
}
const mousePosX = evt.x;
const mousePosY = evt.y;
mouseXVel = (mousePosX - previousPosX);
mouseYVel = (mousePosY - previousPosY);
previousPosX = mousePosX;
previousPosY = mousePosY;
}
});
const deactivateMouseMovement = () => {
previousPosX = null;
previousPosY = null;
mouseXVel = 0.0;
mouseYVel = 0.0;
drag = false;
};
canvas.addEventListener('mouseup', deactivateMouseMovement);
canvas.addEventListener('mouseleave', deactivateMouseMovement);
// 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
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');
}
// 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') {
evt.preventDefault();
}
if (cameraMode === 'orbital') {
handleKeyboardInput();
}
});
window.addEventListener('keyup', (evt) => {
keysPressed.delete(evt.key);
if (cameraMode === 'orbital') {
handleKeyboardInput();
}
});
// Window resize
window.addEventListener('resize', () => {
updateCanvasSize(canvas);
});
// Start rendering
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() { var drag = false;
let modeText = document.getElementById('camera-mode'); var previousPosX: number | null;
if (!modeText) { var previousPosY: number | null;
modeText = document.createElement('div'); canvas.addEventListener('mousedown', function (evt) {
modeText.id = 'camera-mode'; drag = true;
modeText.style.cssText = 'position: absolute; top: 40px; left: 10px; color: white; font-family: monospace; font-size: 14px;'; }, false);
document.body.appendChild(modeText); 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;
} }
modeText.textContent = `Camera: ${cameraMode.toUpperCase()}`; canvas.addEventListener('mouseup', deactivateMouseMovement, false);
canvas.addEventListener('mouseleave', deactivateMouseMovement, false);
// Keyboard controls
window.addEventListener('keydown', (evt) => {
keysPressed.add(evt.key);
// Reset camera on 'R' key
if (evt.key === 'r' || evt.key === 'R') {
camera = new Camera(); // Reset to initial position
}
// Prevent default for space to avoid page scroll
if (evt.key === ' ') {
evt.preventDefault();
}
});
window.addEventListener('keyup', (evt) => {
keysPressed.delete(evt.key);
});
// Window resize handler
window.addEventListener('resize', () => {
updateCanvasSize(canvas);
});
// Wireframe toggle handler
window.addEventListener('toggleWireframe', () => {
wireframeMode = !wireframeMode;
const wireframeBtn = document.getElementById('wireframe-toggle');
if (wireframeBtn) {
wireframeBtn.textContent = `Wireframe: ${wireframeMode ? 'ON' : 'OFF'}`;
}
console.log(`Wireframe mode: ${wireframeMode ? 'ON' : 'OFF'}`);
});
// Ocean settings sliders
window.addEventListener('waveHeightChange', ((evt: CustomEvent) => {
waveHeight = evt.detail;
}) as EventListener);
window.addEventListener('waveSpeedChange', ((evt: CustomEvent) => {
waveSpeed = evt.detail;
}) as EventListener);
window.addEventListener('foamIntensityChange', ((evt: CustomEvent) => {
foamIntensity = evt.detail;
}) as EventListener);
window.addEventListener('glitterIntensityChange', ((evt: CustomEvent) => {
glitterIntensity = evt.detail;
}) as EventListener);
initShaders();
initGeometry();
initFBO();
oceanLOD = new OceanLOD();
oceanLOD.initVAO(gl);
console.log(`Ocean LOD initialized with ${oceanLOD.getGridCount()} patches`);
skybox = new Skybox();
skybox.initVAO(gl);
console.log('Skybox initialized');
camera = new Camera();
//Check if any errors apeared during init.
if (gl.getError() != gl.NO_ERROR) {
console.log("OpenGL Error!: ");
}
drawScene();
} }
main(); 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", "target": "ES2020",
"module": "ESNext", "module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"], "lib": ["ES2020", "DOM", "DOM.Iterable"],
"types": ["@webgpu/types"],
"sourceMap": true, "sourceMap": true,
"outDir": "./build/", "outDir": "./build/",
"strict": true, "strict": true,
@@ -15,5 +14,5 @@
"skipLibCheck": true "skipLibCheck": true
}, },
"include": ["src/**/*"], "include": ["src/**/*"],
"exclude": ["node_modules", "dist", "build", "src/main_webgl.ts"] "exclude": ["node_modules", "dist", "build"]
} }