SOURCE / PINNED RELEASE
Made of little things.
Chase'n Those Narly Waves
- Release
- 0c7dde9d4921…
- Author-recorded commit
- 88e58516be17…
- License
- LICENSE
- Author’s source reference
- nostr://npub1ny7l44q0au5xkv3mjgt6zks5f0e2ydefslq4nhh2ve2xypgc2mqqat8qx2/wss%3A%2F%2Fgit.napplet.soy%2F/n-836af5a08b9
Archive hash verified: e1acfdbe67c54dd4…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
// The ocean: a big moving water surface drawn on the graphics card (a "shader").
// The same wave math lives here in TypeScript (heightAt) and in GLSL (the shader
// code below), so the surfer, rocks and sharks sit exactly on the water.
//
// Directions: x = left/right, y = up, z = toward the camera (the horizon is -z).
import * as THREE from 'three';
import { WAVE_BACK_WIDTH, WAVE_FRONT_WIDTH } from '../game.js';
/** Small waves (chop): direction x, direction z, wavelength, height, speed. */
const CHOP: [number, number, number, number, number][] = [
[0.2, 1.0, 9.0, 0.16, 1.0],
[-0.5, 0.85, 5.5, 0.09, 1.3],
[0.7, 0.7, 3.4, 0.05, 1.7],
[-0.1, 1.0, 2.1, 0.03, 2.2],
[0.9, -0.4, 13.0, 0.12, 0.8],
];
/** Up to this many rocks/sharks get foam around them. */
export const MAX_FOAM_SPOTS = 12;
export interface OceanState {
time: number;
/** How far the world has scrolled toward you. */
scroll: number;
/** Where the big wave's crest is (z), and how tall it is (0 = no wave). */
swellZ: number;
swellHeight: number;
playerX: number;
/** 0..1: how strong the foamy trail behind the board is. */
wake: number;
}
/** How much the big wave lifts the water at a spot. */
export function swellAt(state: OceanState, x: number, z: number): number {
if (state.swellHeight <= 0) return 0;
const s = z - state.swellZ;
const width = s < 0 ? WAVE_FRONT_WIDTH : WAVE_BACK_WIDTH; // steeper on the front side (the way it rolls)
const ripple = 1 + 0.15 * Math.sin(x * 0.15 + state.time * 0.3);
return state.swellHeight * ripple * Math.exp(-(s * s) / (width * width));
}
function chopAt(state: OceanState, x: number, z: number): number {
let height = 0;
const zz = z - state.scroll;
for (const [dx, dz, length, amp, speed] of CHOP) {
const k = (Math.PI * 2) / length;
const len = Math.hypot(dx, dz);
height += amp * Math.sin(k * ((dx / len) * x + (dz / len) * zz) + state.time * speed * 1.6);
}
return height;
}
/** How high the water is at a spot (matches the shader). */
export function heightAt(state: OceanState, x: number, z: number): number {
return chopAt(state, x, z) + swellAt(state, x, z);
}
const GLSL_WAVES = /* glsl */ `
uniform float uTime;
uniform float uScroll;
uniform float uSwellZ;
uniform float uSwellH;
float swellAt(vec2 p) {
if (uSwellH <= 0.0) return 0.0;
float s = p.y - uSwellZ;
float width = s < 0.0 ? ${WAVE_FRONT_WIDTH.toFixed(2)} : ${WAVE_BACK_WIDTH.toFixed(2)};
float ripple = 1.0 + 0.15 * sin(p.x * 0.15 + uTime * 0.3);
return uSwellH * ripple * exp(-(s * s) / (width * width));
}
float chopAt(vec2 p) {
float h = 0.0;
float zz = p.y - uScroll;
${CHOP.map(([dx, dz, length, amp, speed]) => {
const len = Math.hypot(dx, dz);
const k = (Math.PI * 2) / length;
return `h += ${amp.toFixed(4)} * sin(${k.toFixed(5)} * (${(dx / len).toFixed(5)} * p.x + ${(dz / len).toFixed(5)} * zz) + uTime * ${(speed * 1.6).toFixed(4)});`;
}).join('\n ')}
return h;
}
`;
const vertexShader = /* glsl */ `
${GLSL_WAVES}
varying vec3 vWorld;
varying vec3 vNormal;
varying float vSwell;
varying float vChop;
float heightAt(vec2 p) { return chopAt(p) + swellAt(p); }
void main() {
vec4 world = modelMatrix * vec4(position, 1.0);
vec2 p = world.xz;
float e = 0.2;
float h = heightAt(p);
float hx = heightAt(p + vec2(e, 0.0));
float hz = heightAt(p + vec2(0.0, e));
vNormal = normalize(vec3(h - hx, e, h - hz));
vSwell = uSwellH > 0.0 ? swellAt(p) / uSwellH : 0.0;
vChop = chopAt(p);
world.y += h;
vWorld = world.xyz;
gl_Position = projectionMatrix * viewMatrix * world;
}
`;
const fragmentShader = /* glsl */ `
uniform float uTime;
uniform float uScroll;
uniform vec3 uSunDir;
uniform vec3 uSunColor;
uniform vec3 uHorizon;
uniform vec3 uZenith;
uniform float uPlayerX;
uniform float uWake;
uniform vec3 uFoamSpots[${MAX_FOAM_SPOTS}];
uniform bool uIsFar;
uniform vec2 uNearCenter;
varying vec3 vWorld;
varying vec3 vNormal;
varying float vSwell;
varying float vChop;
float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
float noise(vec2 p) {
vec2 i = floor(p), f = fract(p);
f = f * f * (3.0 - 2.0 * f);
return mix(mix(hash(i), hash(i + vec2(1, 0)), f.x), mix(hash(i + vec2(0, 1)), hash(i + vec2(1, 1)), f.x), f.y);
}
float foamNoise(vec2 p) {
return noise(p * 1.7) * 0.6 + noise(p * 4.3 + 7.0) * 0.4;
}
vec3 skyColor(vec3 dir) {
float up = clamp(dir.y, 0.0, 1.0);
vec3 sky = mix(uHorizon, uZenith, pow(up, 0.45));
float sunGlow = pow(max(dot(dir, uSunDir), 0.0), 8.0);
return sky + uSunColor * sunGlow * 0.6;
}
// Tiny ripples so the water sparkles up close.
vec3 rippleNormal(vec2 p) {
vec2 q = vec2(p.x, p.y - uScroll);
float t = uTime;
vec2 d = vec2(0.0);
d += vec2(0.8, 0.6) * cos(dot(q, vec2(0.8, 0.6)) * 3.1 + t * 2.3) * 0.12;
d += vec2(-0.6, 0.8) * cos(dot(q, vec2(-0.6, 0.8)) * 4.7 + t * 2.9) * 0.08;
d += vec2(0.2, -1.0) * cos(dot(q, vec2(0.2, -1.0)) * 7.3 + t * 3.7) * 0.05;
return vec3(-d.x, 0.0, -d.y);
}
void main() {
if (uIsFar && vWorld.z > uNearCenter.y + 2.0 && abs(vWorld.x - uNearCenter.x) < 78.0) discard;
float dist = length(vWorld - cameraPosition);
float detail = 1.0 - smoothstep(20.0, 90.0, dist);
vec3 n = normalize(vNormal + rippleNormal(vWorld.xz) * detail);
vec3 v = normalize(cameraPosition - vWorld);
float facing = max(dot(n, v), 0.0);
float fresnel = 0.02 + 0.98 * pow(1.0 - facing, 5.0);
vec3 r = reflect(-v, n);
r.y = abs(r.y);
// Deep blue water, glowing green-blue where light shines through the waves.
vec3 deep = vec3(0.005, 0.06, 0.13);
vec3 shallow = vec3(0.03, 0.36, 0.40);
float glow = clamp(vSwell * 0.9 + max(vChop, 0.0) * 1.5, 0.0, 1.0);
float backLight = pow(max(dot(-v, uSunDir) * 0.5 + 0.5, 0.0), 3.0);
vec3 water = mix(deep, shallow, glow * (0.4 + backLight * 0.8));
water += uSunColor * 0.03 * max(dot(n, uSunDir), 0.0);
vec3 col = mix(water, skyColor(r), fresnel);
// Sun sparkles.
float sunDot = max(dot(r, uSunDir), 0.0);
col += uSunColor * (pow(sunDot, 600.0) * 12.0 + pow(sunDot, 60.0) * 0.4);
// Foam: on the wave crest, little whitecaps, the board's trail, and around rocks.
vec2 fp = vec2(vWorld.x, vWorld.z - uScroll);
float fnoise = foamNoise(fp * 1.3 + uTime * 0.2);
float foam = smoothstep(0.82, 1.0, vSwell) * smoothstep(0.35, 0.75, fnoise);
foam += smoothstep(0.24, 0.34, vChop) * smoothstep(0.5, 0.8, fnoise) * 0.6;
float behind = vWorld.z;
if (uWake > 0.0 && behind > 0.0 && behind < 10.0) {
float dx = abs(vWorld.x - uPlayerX);
float line = abs(dx - (0.15 + behind * 0.3));
float trail = 1.0 - smoothstep(0.0, 0.06 + behind * 0.03, line);
float center = (1.0 - smoothstep(0.0, 0.25, dx)) * (1.0 - smoothstep(0.0, 3.0, behind));
float bubbles = smoothstep(0.45, 0.75, foamNoise(fp * 3.0 + uTime * 0.5));
foam += (trail + center * 0.6) * pow(1.0 - behind / 10.0, 2.0) * uWake * bubbles;
}
for (int i = 0; i < ${MAX_FOAM_SPOTS}; i++) {
vec3 spot = uFoamSpots[i];
if (spot.z <= 0.0) continue;
float d = length(vWorld.xz - spot.xy);
float ring = 1.0 - smoothstep(spot.z * 0.8, spot.z * 1.8, d);
foam += ring * smoothstep(0.3, 0.7, fnoise + 0.2);
}
foam = clamp(foam, 0.0, 1.0) * (1.0 - smoothstep(60.0, 140.0, dist));
vec3 foamColor = vec3(0.85, 0.88, 0.9) * (0.75 + 0.25 * max(dot(n, uSunDir), 0.0)) + uSunColor * 0.08;
col = mix(col, foamColor, foam);
// Haze toward the horizon.
float fog = 1.0 - exp(-dist * 0.0045);
col = mix(col, uHorizon, fog * 0.9);
gl_FragColor = vec4(col, 1.0);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
`;
export class Ocean {
readonly group = new THREE.Group();
private readonly near: THREE.Mesh;
private readonly far: THREE.Mesh;
private readonly nearMaterial: THREE.ShaderMaterial;
private readonly farMaterial: THREE.ShaderMaterial;
private readonly gridStep: number;
constructor(sunDir: THREE.Vector3, sunColor: THREE.Color, horizon: THREE.Color, zenith: THREE.Color) {
const uniforms = {
uTime: { value: 0 },
uScroll: { value: 0 },
uSwellZ: { value: -100 },
uSwellH: { value: 0 },
uSunDir: { value: sunDir.clone() },
uSunColor: { value: sunColor.clone() },
uHorizon: { value: horizon.clone() },
uZenith: { value: zenith.clone() },
uPlayerX: { value: 0 },
uWake: { value: 0 },
uFoamSpots: { value: Array.from({ length: MAX_FOAM_SPOTS }, () => new THREE.Vector3()) },
uIsFar: { value: false },
uNearCenter: { value: new THREE.Vector2(0, -128) },
};
// Detailed water near you...
const width = 160;
const segments = 320;
this.gridStep = width / segments;
const nearGeometry = new THREE.PlaneGeometry(width, 140, segments, 280);
nearGeometry.rotateX(-Math.PI / 2);
nearGeometry.translate(0, 0, -58);
this.nearMaterial = new THREE.ShaderMaterial({ uniforms, vertexShader, fragmentShader, toneMapped: false });
this.near = new THREE.Mesh(nearGeometry, this.nearMaterial);
this.near.frustumCulled = false;
// ...and simple flat water all the way to the horizon.
const farGeometry = new THREE.PlaneGeometry(6000, 6000, 1, 1);
farGeometry.rotateX(-Math.PI / 2);
this.farMaterial = new THREE.ShaderMaterial({
uniforms: { ...uniforms, uIsFar: { value: true } },
vertexShader,
fragmentShader,
toneMapped: false,
});
this.far = new THREE.Mesh(farGeometry, this.farMaterial);
this.far.frustumCulled = false;
this.group.add(this.far, this.near);
}
update(state: OceanState, cameraX: number, foamSpots: THREE.Vector3[]): void {
// Snap to the grid so the water doesn't shimmer as the camera slides.
const snappedX = Math.round(cameraX / this.gridStep) * this.gridStep;
this.near.position.x = snappedX;
this.far.position.x = snappedX;
for (const material of [this.nearMaterial, this.farMaterial]) {
const u = material.uniforms;
u.uTime!.value = state.time;
u.uScroll!.value = state.scroll;
u.uSwellZ!.value = state.swellZ;
u.uSwellH!.value = state.swellHeight;
u.uPlayerX!.value = state.playerX;
u.uWake!.value = state.wake;
(u.uNearCenter!.value as THREE.Vector2).set(snappedX, -128);
const spots = u.uFoamSpots!.value as THREE.Vector3[];
spots.forEach((spot, i) => {
const source = foamSpots[i];
if (source) spot.copy(source);
else spot.set(0, 0, 0);
});
}
}
}
