Back to Chase'n Those Narly Waves
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.

src/render/ocean.ts
// 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);
      });
    }
  }
}