From cb3a16848f333565b5b35b4820bba5cd512d10cf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 10 Jun 2026 16:04:53 +0000 Subject: [PATCH 1/6] pirate-ship: wave model, island field, ship rigid-body physics + Node tests Co-authored-by: ilikevibecoding --- pirate-ship/src/islandField.js | 147 +++++++++++++ pirate-ship/src/noise.js | 75 +++++++ pirate-ship/src/physics.js | 340 ++++++++++++++++++++++++++++++ pirate-ship/src/waves.js | 197 +++++++++++++++++ pirate-ship/test/physics.test.mjs | 176 ++++++++++++++++ 5 files changed, 935 insertions(+) create mode 100644 pirate-ship/src/islandField.js create mode 100644 pirate-ship/src/noise.js create mode 100644 pirate-ship/src/physics.js create mode 100644 pirate-ship/src/waves.js create mode 100644 pirate-ship/test/physics.test.mjs diff --git a/pirate-ship/src/islandField.js b/pirate-ship/src/islandField.js new file mode 100644 index 0000000..40d4a4d --- /dev/null +++ b/pirate-ship/src/islandField.js @@ -0,0 +1,147 @@ +// --------------------------------------------------------------------------- +// islandField.js — pure math describing the archipelago (no three.js). +// +// Every island is a radial mound whose shoreline radius wobbles with three +// deterministic harmonics. The SAME function is used for: +// * terrain mesh generation (islands.js) +// * ship collision / grounding (physics.js) +// * shallow-water tint + shore foam in the ocean shader (GLSL mirror below) +// --------------------------------------------------------------------------- + +import { mulberry32 } from './noise.js'; + +export const SEA_FLOOR_DEPTH = 16; // open-ocean seabed depth (m) +export const BEACH_SLOPE = 0.085; // vertical rise per metre across the beach + +function makeIsland(cx, cz, r, height, seed) { + const rand = mulberry32(seed); + return { + cx, + cz, + r, + height, + seed, + beachW: 16 + r * 0.05, + w1: 0.05 + rand() * 0.07, + p1: rand() * Math.PI * 2, + w2: 0.03 + rand() * 0.05, + p2: rand() * Math.PI * 2, + w3: 0.02 + rand() * 0.035, + p3: rand() * Math.PI * 2, + }; +} + +// The cove: a large jungle arc to the south-west plus scattered islets, +// leaving wide open channels for sailing. Ship spawns near the origin. +export const ISLANDS = [ + // main jungle island (three overlapping mounds form a crescent) + makeIsland(-520, -180, 230, 58, 101), + makeIsland(-330, -430, 262, 74, 202), + makeIsland(20, -560, 212, 52, 303), + // outlying islets + makeIsland(470, -290, 96, 26, 404), + makeIsland(640, 180, 122, 36, 505), + makeIsland(265, 430, 86, 22, 606), + makeIsland(-250, 395, 112, 30, 707), + makeIsland(-640, 270, 78, 18, 808), +]; + +export const SPAWN = { x: 90, z: 60, heading: Math.PI * 0.75 }; + +/** Wobbly shoreline radius of an island at polar angle `a` from its centre. */ +export function shoreRadius(isl, a) { + return ( + isl.r * + (1 + + isl.w1 * Math.sin(3 * a + isl.p1) + + isl.w2 * Math.sin(5 * a + isl.p2) + + isl.w3 * Math.sin(7 * a + isl.p3)) + ); +} + +/** Terrain height of a single island at world (x, z). Sea level = 0. */ +export function islandHeightAt(isl, x, z) { + const dx = x - isl.cx; + const dz = z - isl.cz; + const d = Math.hypot(dx, dz); + const a = Math.atan2(dz, dx); + const reff = shoreRadius(isl, a); + // Gentle beach plane crossing sea level exactly at the shoreline... + let y = Math.min(BEACH_SLOPE * (reff - d), 2.2); + // ...plus the jungle mound rising further inland. + const u = 1 - d / reff; + if (u > 0.1) { + const m = Math.min((u - 0.1) / 0.9, 1); + y += isl.height * Math.pow(m, 1.6); + } + return Math.max(y, -SEA_FLOOR_DEPTH); +} + +/** Combined terrain height (max over islands). -SEA_FLOOR_DEPTH in open water. */ +export function terrainHeightAt(x, z) { + let y = -SEA_FLOOR_DEPTH; + for (let i = 0; i < ISLANDS.length; i++) { + const isl = ISLANDS[i]; + // cheap reject: outside the island's maximum possible footprint + const dx = x - isl.cx; + const dz = z - isl.cz; + const maxR = isl.r * 1.18 + SEA_FLOOR_DEPTH / BEACH_SLOPE; + if (dx * dx + dz * dz > maxR * maxR) continue; + const h = islandHeightAt(isl, x, z); + if (h > y) y = h; + } + return y; +} + +/** Finite-difference terrain gradient (uphill direction). */ +export function terrainGradientAt(x, z, out) { + const e = 2.0; + out.x = (terrainHeightAt(x + e, z) - terrainHeightAt(x - e, z)) / (2 * e); + out.z = (terrainHeightAt(x, z + e) - terrainHeightAt(x, z - e)) / (2 * e); + return out; +} + +// --- GPU mirror -------------------------------------------------------------- + +/** Pack island data for the ocean shader. */ +export function packIslands() { + const n = ISLANDS.length; + const a = new Float32Array(n * 4); // cx, cz, r, beachW + const b = new Float32Array(n * 4); // w1, p1, w2, p2 + const c = new Float32Array(n * 4); // w3, p3, height, 0 + ISLANDS.forEach((isl, i) => { + a.set([isl.cx, isl.cz, isl.r, isl.beachW], i * 4); + b.set([isl.w1, isl.p1, isl.w2, isl.p2], i * 4); + c.set([isl.w3, isl.p3, isl.height, 0], i * 4); + }); + return { a, b, c, count: n }; +} + +/** + * GLSL mirror of the terrain function. Returns the approximate seabed/terrain + * height at a world position — used for shallow tint and shore foam bands. + */ +export const ISLAND_GLSL = /* glsl */ ` +float terrainHeight(vec2 p, vec4 islA[NUM_ISLANDS], vec4 islB[NUM_ISLANDS], vec4 islC[NUM_ISLANDS]) { + float y = -SEA_FLOOR_DEPTH; + for (int i = 0; i < NUM_ISLANDS; i++) { + vec2 d2 = p - islA[i].xy; + float d = length(d2); + float r = islA[i].z; + if (d > r * 1.18 + SEA_FLOOR_DEPTH / BEACH_SLOPE) continue; + float a = atan(d2.y, d2.x); + float reff = r * (1.0 + + islB[i].x * sin(3.0 * a + islB[i].y) + + islB[i].z * sin(5.0 * a + islB[i].w) + + islC[i].x * sin(7.0 * a + islC[i].y)); + float h = min(BEACH_SLOPE * (reff - d), 2.2); + float u = 1.0 - d / reff; + if (u > 0.1) { + float m = min((u - 0.1) / 0.9, 1.0); + h += islC[i].z * pow(m, 1.6); + } + y = max(y, h); + } + return y; +} +`; diff --git a/pirate-ship/src/noise.js b/pirate-ship/src/noise.js new file mode 100644 index 0000000..720fc45 --- /dev/null +++ b/pirate-ship/src/noise.js @@ -0,0 +1,75 @@ +// Tiny seeded 2D value-noise + fBm. Dependency-free, deterministic. +// Used for island terrain detail and vegetation scatter. + +export function mulberry32(seed) { + let a = seed >>> 0; + return function () { + a |= 0; + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +export function createNoise2D(seed = 1337) { + const rand = mulberry32(seed); + const SIZE = 256; + const perm = new Uint8Array(SIZE * 2); + const grads = new Float32Array(SIZE * 2); + const p = new Uint8Array(SIZE); + for (let i = 0; i < SIZE; i++) p[i] = i; + for (let i = SIZE - 1; i > 0; i--) { + const j = (rand() * (i + 1)) | 0; + const tmp = p[i]; + p[i] = p[j]; + p[j] = tmp; + } + for (let i = 0; i < SIZE * 2; i++) perm[i] = p[i & 255]; + for (let i = 0; i < SIZE; i++) { + const a = rand() * Math.PI * 2; + grads[i * 2] = Math.cos(a); + grads[i * 2 + 1] = Math.sin(a); + } + + function dotGrad(ix, iz, fx, fz) { + const g = perm[(ix & 255) + perm[iz & 255]]; + return grads[g * 2] * fx + grads[g * 2 + 1] * fz; + } + + const fade = (t) => t * t * t * (t * (t * 6 - 15) + 10); + + // Perlin-style gradient noise, output roughly in [-1, 1] + return function noise2D(x, z) { + const ix = Math.floor(x); + const iz = Math.floor(z); + const fx = x - ix; + const fz = z - iz; + const u = fade(fx); + const v = fade(fz); + const n00 = dotGrad(ix, iz, fx, fz); + const n10 = dotGrad(ix + 1, iz, fx - 1, fz); + const n01 = dotGrad(ix, iz + 1, fx, fz - 1); + const n11 = dotGrad(ix + 1, iz + 1, fx - 1, fz - 1); + const nx0 = n00 + (n10 - n00) * u; + const nx1 = n01 + (n11 - n01) * u; + return (nx0 + (nx1 - nx0) * v) * 1.9; + }; +} + +export function createFbm2D(seed = 1337, octaves = 4, lacunarity = 2.0, gain = 0.5) { + const noise = createNoise2D(seed); + return function fbm(x, z) { + let amp = 1; + let freq = 1; + let sum = 0; + let norm = 0; + for (let o = 0; o < octaves; o++) { + sum += noise(x * freq, z * freq) * amp; + norm += amp; + amp *= gain; + freq *= lacunarity; + } + return sum / norm; + }; +} diff --git a/pirate-ship/src/physics.js b/pirate-ship/src/physics.js new file mode 100644 index 0000000..dbe8d46 --- /dev/null +++ b/pirate-ship/src/physics.js @@ -0,0 +1,340 @@ +// --------------------------------------------------------------------------- +// physics.js — lightweight rigid-body ship physics (pure JS, no three.js). +// +// One dynamic body: the ship. Floats on the Gerstner ocean via buoyancy +// probes spread over the hull footprint. Each probe samples the SAME wave +// function the GPU renders, so motion matches the visible surface exactly. +// +// Mass-normalised (m = 1): forces are accelerations. Local frame: +// +x = starboard (right), +y = up, +z = forward. +// --------------------------------------------------------------------------- + +import { sampleAt } from './waves.js'; +import { terrainHeightAt, terrainGradientAt, SPAWN } from './islandField.js'; + +const GRAV = 9.81; + +// --- minimal vec3 / quat helpers (allocation-free) --------------------------- +function rotate(q, x, y, z, out) { + // v' = q * v * q^-1 + const { x: qx, y: qy, z: qz, w: qw } = q; + const ix = qw * x + qy * z - qz * y; + const iy = qw * y + qz * x - qx * z; + const iz = qw * z + qx * y - qy * x; + const iw = -qx * x - qy * y - qz * z; + out.x = ix * qw + iw * -qx + iy * -qz - iz * -qy; + out.y = iy * qw + iw * -qy + iz * -qx - ix * -qz; + out.z = iz * qw + iw * -qz + ix * -qy - iy * -qx; + return out; +} +function rotateInv(q, x, y, z, out) { + const inv = { x: -q.x, y: -q.y, z: -q.z, w: q.w }; + return rotate(inv, x, y, z, out); +} + +// --- tuning ------------------------------------------------------------------- +export const TUNE = { + draft: 1.45, // probe depth ramp distance (m) + keelDepth: 1.8, // keel below COM (m), used for grounding + buoyTotal: 1.8, // total buoyancy at full draft, in multiples of gravity + vDamp: 1.7, // total vertical water damping + hDamp: 0.07, // horizontal water coupling (wave drift / surface friction) + maxThrust: 2.3, // full-sail acceleration (m/s^2) + dragFwd1: 0.02, + dragFwd2: 0.014, + dragLat1: 0.65, + dragLat2: 0.35, + rudderMax: 0.45, // rad + rudderRate: 1.6, // rad/s + rudderTorque: 1.7, + angDamp: { pitch: 50, roll: 13, yaw: 68 }, + inertia: { pitch: 72, roll: 11, yaw: 78 }, + heelTurn: 0.55, // outward heel while turning + heelWind: 0.5, // heel from beam wind on sails + keelRighting: 16, // artificial righting torque (anti-capsize) + anchorDrag: 1.4, + groundSpring: 26, + groundFriction: 2.2, +}; + +export const SAIL_SETTINGS = [ + { name: 'Anchored', frac: 0 }, + { name: 'Slow', frac: 0.35 }, + { name: 'Half sail', frac: 0.68 }, + { name: 'Full sail', frac: 1.0 }, +]; + +// Buoyancy probes across the hull footprint (local space). +// Wider mid-ship rows carry more displaced volume than bow/stern tips. +function buildProbes() { + const probes = []; + const rows = [ + { z: -11.5, w: 0.8 }, + { z: -5.0, w: 1.0 }, + { z: 1.5, w: 1.0 }, + { z: 8.0, w: 0.88 }, + ]; + for (const row of rows) { + for (const x of [-2.55, 0, 2.55]) { + probes.push({ x, y: -1.0, z: row.z, w: row.w * (x === 0 ? 1.08 : 1) }); + } + } + probes.push({ x: 0, y: -0.85, z: 12.6, w: 0.55 }); // bow tip + probes.push({ x: 0, y: -0.95, z: -13.2, w: 0.62 }); // stern tip + const sum = probes.reduce((s, p) => s + p.w, 0); + for (const p of probes) p.w /= sum; + return probes; +} + +// Grounding contact circles along the keel line (local z offsets). +const CONTACTS = [11, 4, -4, -11.5]; + +export class ShipPhysics { + constructor() { + this.probes = buildProbes(); + this.pos = { x: SPAWN.x, y: 0.4, z: SPAWN.z }; + this.quat = { x: 0, y: Math.sin(SPAWN.heading / 2), z: 0, w: Math.cos(SPAWN.heading / 2) }; + this.vel = { x: 0, y: 0, z: 0 }; + this.omegaL = { x: 0, y: 0, z: 0 }; // angular velocity, LOCAL frame + + this.sailIndex = 0; + this.rudderInput = 0; // +1 = starboard turn (D), -1 = port turn (A) + this.rudder = 0; + this.anchored = true; + this.aground = false; + + this.wind = { x: Math.SQRT1_2, z: Math.SQRT1_2 }; + + // scratch objects (no per-step allocation) + this._f = { x: 0, y: 0, z: 0 }; + this._tl = { x: 0, y: 0, z: 0 }; + this._r = { x: 0, y: 0, z: 0 }; + this._v = { x: 0, y: 0, z: 0 }; + this._w = { x: 0, y: 0, z: 0 }; + this._g = { x: 0, z: 0 }; + this._sample = { height: 0, nx: 0, ny: 1, nz: 0, vx: 0, vy: 0, vz: 0 }; + this._fwd = { x: 0, y: 0, z: 1 }; + this._right = { x: 1, y: 0, z: 0 }; + this._up = { x: 0, y: 1, z: 0 }; + } + + get speed() { + const f = this._fwd; + return this.vel.x * f.x + this.vel.y * f.y + this.vel.z * f.z; + } + + get heading() { + return Math.atan2(this._fwd.x, this._fwd.z); + } + + get sail() { + return SAIL_SETTINGS[this.sailIndex]; + } + + setSail(i) { + this.sailIndex = Math.max(0, Math.min(SAIL_SETTINGS.length - 1, i)); + if (this.sailIndex > 0) this.anchored = false; + } + + changeSail(delta) { + this.setSail(this.sailIndex + delta); + if (this.sailIndex === 0) this.anchored = true; + } + + toggleAnchor() { + this.anchored = !this.anchored; + if (this.anchored) this.sailIndex = 0; + } + + reset() { + this.pos.x = SPAWN.x; + this.pos.y = 0.4; + this.pos.z = SPAWN.z; + const h = SPAWN.heading; + this.quat.x = 0; + this.quat.y = Math.sin(h / 2); + this.quat.z = 0; + this.quat.w = Math.cos(h / 2); + this.vel.x = this.vel.y = this.vel.z = 0; + this.omegaL.x = this.omegaL.y = this.omegaL.z = 0; + this.sailIndex = 0; + this.anchored = true; + this.rudder = 0; + } + + /** force F at world offset r from COM -> accumulate force + torque */ + _applyAt(fx, fy, fz, rx, ry, rz, F, T) { + F.x += fx; + F.y += fy; + F.z += fz; + T.x += ry * fz - rz * fy; + T.y += rz * fx - rx * fz; + T.z += rx * fy - ry * fx; + } + + step(dt, t) { + const T = TUNE; + const q = this.quat; + const F = this._f; // accumulated world force + const TQ = this._w; // accumulated world torque + F.x = F.y = F.z = 0; + TQ.x = TQ.y = TQ.z = 0; + + // basis vectors + rotate(q, 0, 0, 1, this._fwd); + rotate(q, 1, 0, 0, this._right); + rotate(q, 0, 1, 0, this._up); + const fwd = this._fwd; + const right = this._right; + const up = this._up; + + // world angular velocity (for probe velocities) + const omegaW = rotate(q, this.omegaL.x, this.omegaL.y, this.omegaL.z, { x: 0, y: 0, z: 0 }); + + // --- gravity + F.y -= GRAV; + + // --- buoyancy + water damping per probe + let submergedFrac = 0; + for (let i = 0; i < this.probes.length; i++) { + const p = this.probes[i]; + const r = rotate(q, p.x, p.y, p.z, this._r); // world offset from COM + const px = this.pos.x + r.x; + const py = this.pos.y + r.y; + const pz = this.pos.z + r.z; + const s = sampleAt(px, pz, t, this._sample); + const depth = s.height - py; + if (depth <= 0) continue; + const ramp = Math.min(depth / T.draft, 1.6); + submergedFrac += p.w * Math.min(ramp, 1); + // buoyant push straight up, applied at the probe -> natural righting + let fy = GRAV * T.buoyTotal * p.w * ramp; + // probe velocity relative to the moving water surface + const pvx = this.vel.x + omegaW.y * r.z - omegaW.z * r.y; + const pvy = this.vel.y + omegaW.z * r.x - omegaW.x * r.z; + const pvz = this.vel.z + omegaW.x * r.y - omegaW.y * r.x; + const wet = Math.min(ramp, 1); + fy -= (pvy - s.vy) * T.vDamp * p.w * wet; + const fx = -(pvx - s.vx) * T.hDamp * p.w * wet; + const fz = -(pvz - s.vz) * T.hDamp * p.w * wet; + this._applyAt(fx, fy, fz, r.x, r.y, r.z, F, TQ); + } + this.submergedFrac = submergedFrac; + + // --- velocity decomposition + const vf = this.vel.x * fwd.x + this.vel.z * fwd.z; // forward speed (planar) + const vl = this.vel.x * right.x + this.vel.z * right.z; // lateral speed + + // --- sails + const sailFrac = this.anchored ? 0 : SAIL_SETTINGS[this.sailIndex].frac; + if (sailFrac > 0) { + const windDot = this.wind.x * fwd.x + this.wind.z * fwd.z; // -1..1 + const windFactor = 0.45 + 0.55 * Math.max(0, windDot * 0.5 + 0.5); + const thrust = T.maxThrust * sailFrac * windFactor * Math.min(1, submergedFrac * 3); + F.x += fwd.x * thrust; + F.z += fwd.z * thrust; + // beam wind heels the ship + const beam = this.wind.x * right.x + this.wind.z * right.z; + TQ.x += fwd.x * -beam * T.heelWind * sailFrac; + TQ.y += fwd.y * -beam * T.heelWind * sailFrac; + TQ.z += fwd.z * -beam * T.heelWind * sailFrac; + } + + // --- hull drag + const dragF = T.dragFwd1 * vf + T.dragFwd2 * vf * Math.abs(vf); + const dragL = T.dragLat1 * vl + T.dragLat2 * vl * Math.abs(vl); + F.x -= fwd.x * dragF + right.x * dragL; + F.z -= fwd.z * dragF + right.z * dragL; + if (this.anchored) { + F.x -= this.vel.x * T.anchorDrag; + F.z -= this.vel.z * T.anchorDrag; + } + + // --- rudder + const target = this.rudderInput * T.rudderMax; + const dr = target - this.rudder; + const maxStep = T.rudderRate * dt; + this.rudder += Math.max(-maxStep, Math.min(maxStep, dr)); + // yaw torque scales with water flow over the rudder + TQ.y += this.rudder * vf * T.rudderTorque; + // heel outward in turns + const heel = -this.rudder * Math.max(0, vf) * T.heelTurn; + TQ.x += fwd.x * heel; + TQ.y += fwd.y * heel; + TQ.z += fwd.z * heel; + + // --- keel righting assist (anti-capsize) + // Torque axis = cross(shipUp, worldUp) = (-up.z, 0, up.x): rotates the + // ship's up vector back toward world up. + TQ.x += -up.z * T.keelRighting; + TQ.z += up.x * T.keelRighting; + + // --- grounding on islands (soft beaching) + this.aground = false; + for (let i = 0; i < CONTACTS.length; i++) { + const r = rotate(q, 0, -1.0, CONTACTS[i], this._r); + const px = this.pos.x + r.x; + const pz = this.pos.z + r.z; + const keelY = this.pos.y + r.y - (T.keelDepth - 1.0); + const ground = terrainHeightAt(px, pz); + const pen = ground - keelY; + if (pen <= 0) continue; + this.aground = true; + const capped = Math.min(pen, 1.4); + const g = terrainGradientAt(px, pz, this._g); // uphill + const gl = Math.hypot(g.x, g.z) || 1; + const dhx = -g.x / gl; // downhill (push back to sea) + const dhz = -g.z / gl; + // spring push + lift, applied at the contact -> the bow swings off shore + this._applyAt( + dhx * capped * T.groundSpring, + capped * T.groundSpring * 0.35, + dhz * capped * T.groundSpring, + r.x, + r.y, + r.z, + F, + TQ + ); + // friction + F.x -= this.vel.x * T.groundFriction * Math.min(capped, 1); + F.z -= this.vel.z * T.groundFriction * Math.min(capped, 1); + } + + // --- integrate linear + this.vel.x += F.x * dt; + this.vel.y += F.y * dt; + this.vel.z += F.z * dt; + this.pos.x += this.vel.x * dt; + this.pos.y += this.vel.y * dt; + this.pos.z += this.vel.z * dt; + + // --- integrate angular (local frame, diagonal inertia) + const tl = rotateInv(q, TQ.x, TQ.y, TQ.z, this._tl); + const I = T.inertia; + const D = T.angDamp; + this.omegaL.x += ((tl.x - this.omegaL.x * D.pitch) / I.pitch) * dt; + this.omegaL.y += ((tl.y - this.omegaL.y * D.yaw) / I.yaw) * dt; + this.omegaL.z += ((tl.z - this.omegaL.z * D.roll) / I.roll) * dt; + + // quaternion integration: dq = 0.5 * (omega_world) * q + const ow = rotate(q, this.omegaL.x, this.omegaL.y, this.omegaL.z, this._v); + const hx = ow.x * 0.5 * dt; + const hy = ow.y * 0.5 * dt; + const hz = ow.z * 0.5 * dt; + const nqx = q.x + (hx * q.w + hy * q.z - hz * q.y); + const nqy = q.y + (hy * q.w + hz * q.x - hx * q.z); + const nqz = q.z + (hz * q.w + hx * q.y - hy * q.x); + const nqw = q.w + (-hx * q.x - hy * q.y - hz * q.z); + const il = 1 / Math.hypot(nqx, nqy, nqz, nqw); + q.x = nqx * il; + q.y = nqy * il; + q.z = nqz * il; + q.w = nqw * il; + + // refresh cached basis for getters + rotate(q, 0, 0, 1, this._fwd); + rotate(q, 1, 0, 0, this._right); + rotate(q, 0, 1, 0, this._up); + } +} diff --git a/pirate-ship/src/waves.js b/pirate-ship/src/waves.js new file mode 100644 index 0000000..d5f77a8 --- /dev/null +++ b/pirate-ship/src/waves.js @@ -0,0 +1,197 @@ +// --------------------------------------------------------------------------- +// waves.js — single source of truth for the ocean wave model. +// +// A sum of Gerstner (trochoidal) waves. The SAME wave list drives: +// * the GPU ocean shader (vertex displacement + analytic normals), and +// * the CPU physics sampler used for ship buoyancy. +// so the ship floats on exactly the surface you see. +// +// Pure JS module (no three.js import) so it can be unit-tested in Node. +// --------------------------------------------------------------------------- + +const G = 9.81; +const TAU = Math.PI * 2; + +export const WIND = { + // Unit direction the wind blows TOWARD (XZ plane), plus speed in m/s. + dirX: Math.SQRT1_2, + dirZ: Math.SQRT1_2, + speed: 9, +}; + +function makeWave(angleDeg, wavelength, amplitude, steepness, speedMult = 1) { + const a = (angleDeg * Math.PI) / 180; + const k = TAU / wavelength; + return { + dirX: Math.cos(a), + dirZ: Math.sin(a), + k, + // Deep-water dispersion relation: omega = sqrt(g * k) + omega: Math.sqrt(G * k) * speedMult, + amp: amplitude, + steep: steepness, // per-wave Q in [0,1]; sum of Q*k*A must stay < 1 + phase: (angleDeg * 7.3) % TAU, // deterministic de-correlated phase + }; +} + +// Wind blows toward +X+Z (45deg). Wave directions are spread around it. +// GEO waves displace geometry AND drive physics. DETAIL waves are tiny +// ripples evaluated per-pixel in the fragment shader only (sparkle), so the +// physics surface still matches the rendered geometry. +export const GEO_WAVES = [ + makeWave(43, 95, 1.05, 0.62), // primary swell + makeWave(61, 64, 0.62, 0.65), // secondary swell + makeWave(27, 38, 0.34, 0.7), + makeWave(74, 23, 0.21, 0.72), + makeWave(12, 15, 0.135, 0.78), + makeWave(56, 9.5, 0.072, 0.8), + makeWave(33, 6.3, 0.038, 0.85), +]; + +export const DETAIL_WAVES = [ + makeWave(49, 3.7, 0.022, 0.9), + makeWave(18, 2.5, 0.014, 0.9), + makeWave(78, 1.7, 0.009, 0.9), +]; + +// Safety check (dev aid): total steepness must stay below 1 or crests loop. +export function totalSteepness(waves = GEO_WAVES) { + return waves.reduce((s, w) => s + w.steep * w.k * w.amp, 0); +} + +// --- CPU evaluation --------------------------------------------------------- + +/** + * Evaluate the Gerstner sum at undisplaced position (x0, z0). + * Writes displaced position, normal and surface velocity into `out`. + */ +export function evaluateAt(x0, z0, t, out) { + let dx = 0; + let dy = 0; + let dz = 0; + let vx = 0; + let vy = 0; + let vz = 0; + let nx = 0; + let nz = 0; + let ny = 1; + for (let i = 0; i < GEO_WAVES.length; i++) { + const w = GEO_WAVES[i]; + const f = w.k * (w.dirX * x0 + w.dirZ * z0) - w.omega * t + w.phase; + const c = Math.cos(f); + const s = Math.sin(f); + const qa = w.steep * w.amp; + dx += qa * w.dirX * c; + dz += qa * w.dirZ * c; + dy += w.amp * s; + // d/dt of the displacement (surface velocity at this material point) + vx += qa * w.dirX * s * w.omega; + vz += qa * w.dirZ * s * w.omega; + vy += -w.amp * c * w.omega; + // Analytic normal accumulation + const wka = w.k * w.amp; + nx -= w.dirX * wka * c; + nz -= w.dirZ * wka * c; + ny -= w.steep * wka * s; + } + out.x = x0 + dx; + out.y = dy; + out.z = z0 + dz; + out.vx = vx; + out.vy = vy; + out.vz = vz; + const invLen = 1 / Math.hypot(nx, ny, nz); + out.nx = nx * invLen; + out.ny = ny * invLen; + out.nz = nz * invLen; + return out; +} + +const _scratch = { x: 0, y: 0, z: 0, vx: 0, vy: 0, vz: 0, nx: 0, ny: 1, nz: 0 }; + +/** + * True water sample at a given WORLD (x, z): because Gerstner waves displace + * horizontally, we invert the horizontal displacement with a fixed-point + * iteration (converges fast while total steepness < 1). + * Writes { height, nx, ny, nz, vx, vy, vz } into `out` and returns it. + */ +export function sampleAt(x, z, t, out) { + let px = x; + let pz = z; + for (let iter = 0; iter < 3; iter++) { + evaluateAt(px, pz, t, _scratch); + px += x - _scratch.x; + pz += z - _scratch.z; + } + evaluateAt(px, pz, t, _scratch); + out.height = _scratch.y; + out.nx = _scratch.nx; + out.ny = _scratch.ny; + out.nz = _scratch.nz; + out.vx = _scratch.vx; + out.vy = _scratch.vy; + out.vz = _scratch.vz; + return out; +} + +const _h = { height: 0, nx: 0, ny: 1, nz: 0, vx: 0, vy: 0, vz: 0 }; + +/** Convenience: just the height at world (x, z). */ +export function heightAt(x, z, t) { + return sampleAt(x, z, t, _h).height; +} + +// --- GPU data --------------------------------------------------------------- + +/** + * Pack a wave list into flat arrays for shader uniforms. + * waveA[i] = (dirX, dirZ, k, omega); waveB[i] = (amp, steep, phase, 0) + */ +export function packWaves(waves) { + const a = new Float32Array(waves.length * 4); + const b = new Float32Array(waves.length * 4); + waves.forEach((w, i) => { + a[i * 4 + 0] = w.dirX; + a[i * 4 + 1] = w.dirZ; + a[i * 4 + 2] = w.k; + a[i * 4 + 3] = w.omega; + b[i * 4 + 0] = w.amp; + b[i * 4 + 1] = w.steep; + b[i * 4 + 2] = w.phase; + b[i * 4 + 3] = 0; + }); + return { a, b, count: waves.length }; +} + +/** GLSL snippet implementing the same Gerstner sum (geometry waves). */ +export const GERSTNER_GLSL = /* glsl */ ` +struct WaveOut { vec3 disp; vec3 normal; float crest; }; + +WaveOut gerstner(vec2 p0, float t, vec4 waveA[NUM_GEO_WAVES], vec4 waveB[NUM_GEO_WAVES], float fade) { + vec3 disp = vec3(0.0); + vec3 n = vec3(0.0, 1.0, 0.0); + float crest = 0.0; + for (int i = 0; i < NUM_GEO_WAVES; i++) { + vec2 D = waveA[i].xy; + float k = waveA[i].z; + float om = waveA[i].w; + float amp = waveB[i].x * fade; + float q = waveB[i].y; + float f = k * dot(D, p0) - om * t + waveB[i].z; + float c = cos(f); + float s = sin(f); + float qa = q * amp; + disp.xz += qa * D * c; + disp.y += amp * s; + float wka = k * amp; + n.xz -= D * wka * c; + n.y -= q * wka * s; + crest += q * wka * s; + } + WaveOut o; + o.disp = disp; + o.normal = normalize(n); + o.crest = crest; + return o; +} +`; diff --git a/pirate-ship/test/physics.test.mjs b/pirate-ship/test/physics.test.mjs new file mode 100644 index 0000000..ba654ca --- /dev/null +++ b/pirate-ship/test/physics.test.mjs @@ -0,0 +1,176 @@ +// Node test for the pure-math core: wave model + ship physics. +// Run: node pirate-ship/test/physics.test.mjs +import { + GEO_WAVES, + totalSteepness, + evaluateAt, + sampleAt, + heightAt, +} from '../src/waves.js'; +import { ShipPhysics } from '../src/physics.js'; +import { terrainHeightAt } from '../src/islandField.js'; +import { SPAWN } from '../src/islandField.js'; + +let failures = 0; +function check(name, cond, detail = '') { + if (cond) { + console.log(` PASS ${name}`); + } else { + failures++; + console.error(` FAIL ${name} ${detail}`); + } +} + +console.log('\n[1] Wave model sanity'); +const steep = totalSteepness(GEO_WAVES); +check(`total steepness ${steep.toFixed(3)} < 0.9`, steep < 0.9); + +console.log('\n[2] Gerstner horizontal-displacement inversion accuracy'); +{ + let maxErr = 0; + let maxHeightDiff = 0; + const out = { x: 0, y: 0, z: 0, vx: 0, vy: 0, vz: 0, nx: 0, ny: 1, nz: 0 }; + const s = { height: 0, nx: 0, ny: 1, nz: 0, vx: 0, vy: 0, vz: 0 }; + for (let i = 0; i < 500; i++) { + const x = (Math.random() - 0.5) * 1600; + const z = (Math.random() - 0.5) * 1600; + const t = Math.random() * 120; + // ground truth: invert with 12 iterations + let px = x; + let pz = z; + for (let k = 0; k < 12; k++) { + evaluateAt(px, pz, t, out); + px += x - out.x; + pz += z - out.z; + } + evaluateAt(px, pz, t, out); + const truth = out.y; + sampleAt(x, z, t, s); + maxHeightDiff = Math.max(maxHeightDiff, Math.abs(s.height - truth)); + // also confirm the 12-iter inversion itself converged + maxErr = Math.max(maxErr, Math.hypot(out.x - x, out.z - z)); + } + check(`12-iter inversion converges (residual ${maxErr.toExponential(2)} m < 1e-4)`, maxErr < 1e-4); + check( + `3-iter sampler matches truth (max diff ${(maxHeightDiff * 100).toFixed(3)} cm < 1 cm)`, + maxHeightDiff < 0.01 + ); +} + +console.log('\n[3] Flotation: anchored ship settles into bounded wave-riding'); +{ + const ship = new ShipPhysics(); + ship.pos.y = 2.0; // drop from above + const dt = 1 / 60; + let t = 0; + let minY = Infinity; + let maxY = -Infinity; + let maxTrackErr = 0; + for (let i = 0; i < 60 * 60; i++) { + ship.step(dt, t); + t += dt; + if (i > 60 * 20) { + // after settling, track ship vs local water height + minY = Math.min(minY, ship.pos.y); + maxY = Math.max(maxY, ship.pos.y); + const w = heightAt(ship.pos.x, ship.pos.z, t); + maxTrackErr = Math.max(maxTrackErr, Math.abs(ship.pos.y - w)); + } + } + const ok = Number.isFinite(ship.pos.y); + check('no NaN/Infinity after 60 s', ok); + check(`bounded heave (y in [${minY.toFixed(2)}, ${maxY.toFixed(2)}], span < 4 m)`, maxY - minY < 4); + check( + `rides the waves (max |shipY - waterY| = ${maxTrackErr.toFixed(2)} m < 2.5 m)`, + maxTrackErr < 2.5 + ); + check(`stays upright (up.y = ${ship._up.y.toFixed(3)} > 0.95)`, ship._up.y > 0.95); +} + +console.log('\n[4] Drive: full sail accelerates to cruise'); +{ + const ship = new ShipPhysics(); + ship.setSail(3); + const dt = 1 / 60; + let t = 0; + const x0 = ship.pos.x; + const z0 = ship.pos.z; + let speedAt10 = 0; + for (let i = 0; i < 60 * 20; i++) { + ship.step(dt, t); + t += dt; + if (i === 60 * 10) speedAt10 = ship.speed; + } + const dist = Math.hypot(ship.pos.x - x0, ship.pos.z - z0); + check(`moved ${dist.toFixed(0)} m in 20 s (> 100 m)`, dist > 100); + check(`speed after 10 s = ${speedAt10.toFixed(1)} m/s (in 5..16)`, speedAt10 > 5 && speedAt10 < 16); + check(`did not capsize at speed (up.y = ${ship._up.y.toFixed(3)})`, ship._up.y > 0.9); +} + +console.log('\n[5] Steering: rudder turns the ship the right way'); +{ + const mk = (input) => { + const ship = new ShipPhysics(); + ship.setSail(3); + const dt = 1 / 60; + let t = 0; + for (let i = 0; i < 60 * 8; i++) { + ship.step(dt, t); // get up to speed + t += dt; + } + const h0 = ship.heading; + ship.rudderInput = input; + for (let i = 0; i < 60 * 8; i++) { + ship.step(dt, t); + t += dt; + } + // unwrap heading delta to [-pi, pi] + let dh = ship.heading - h0; + while (dh > Math.PI) dh -= 2 * Math.PI; + while (dh < -Math.PI) dh += 2 * Math.PI; + return (dh * 180) / Math.PI; + }; + const dRight = mk(+1); + const dLeft = mk(-1); + check(`D (input +1) turns starboard: ${dRight.toFixed(0)} deg > +25`, dRight > 25); + check(`A (input -1) turns port: ${dLeft.toFixed(0)} deg < -25`, dLeft < -25); +} + +console.log('\n[6] Grounding: sailing into an island stops the ship (no tunnel)'); +{ + const ship = new ShipPhysics(); + // aim straight at the islet at (470, -290) from open water + ship.pos.x = 470; + ship.pos.z = -80; + const heading = Math.PI; // -z direction... heading = atan2(fwd.x, fwd.z); pi -> fwd=(0,0,-1) + ship.quat.x = 0; + ship.quat.y = Math.sin(heading / 2); + ship.quat.z = 0; + ship.quat.w = Math.cos(heading / 2); + ship.setSail(3); + const dt = 1 / 60; + let t = 0; + let grounded = false; + for (let i = 0; i < 60 * 60; i++) { + ship.step(dt, t); + t += dt; + if (ship.aground) grounded = true; + } + const terrain = terrainHeightAt(ship.pos.x, ship.pos.z); + check('ship ran aground at some point', grounded); + check( + `ship did not tunnel inland (terrain under ship = ${terrain.toFixed(1)} m < 3 m)`, + terrain < 3 + ); + check(`no NaN after grounding (${ship.pos.x.toFixed(0)}, ${ship.pos.z.toFixed(0)})`, Number.isFinite(ship.pos.x)); + // reset works + ship.reset(); + check('reset returns to spawn', Math.abs(ship.pos.x - SPAWN.x) < 1e-6 && ship.speed === 0); +} + +console.log(''); +if (failures > 0) { + console.error(`${failures} test(s) FAILED`); + process.exit(1); +} +console.log('All physics tests passed.'); From affbdb74d83ab2aff418cf44149fc98e63f62867 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 10 Jun 2026 16:33:35 +0000 Subject: [PATCH 2/6] =?UTF-8?q?pirate-ship:=20full=20game=20=E2=80=94=20Ge?= =?UTF-8?q?rstner=20ocean=20shader,=20galleon,=20islands,=20jungle,=20effe?= =?UTF-8?q?cts,=20HUD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: ilikevibecoding --- pirate-ship/index.html | 198 +++++++++++++++++ pirate-ship/src/controls.js | 131 ++++++++++++ pirate-ship/src/effects.js | 340 +++++++++++++++++++++++++++++ pirate-ship/src/hud.js | 69 ++++++ pirate-ship/src/islands.js | 118 ++++++++++ pirate-ship/src/main.js | 130 +++++++++++ pirate-ship/src/ocean.js | 234 ++++++++++++++++++++ pirate-ship/src/physics.js | 4 +- pirate-ship/src/ship.js | 391 ++++++++++++++++++++++++++++++++++ pirate-ship/src/sky.js | 208 ++++++++++++++++++ pirate-ship/src/vegetation.js | 246 +++++++++++++++++++++ pirate-ship/src/waves.js | 17 +- 12 files changed, 2079 insertions(+), 7 deletions(-) create mode 100644 pirate-ship/index.html create mode 100644 pirate-ship/src/controls.js create mode 100644 pirate-ship/src/effects.js create mode 100644 pirate-ship/src/hud.js create mode 100644 pirate-ship/src/islands.js create mode 100644 pirate-ship/src/main.js create mode 100644 pirate-ship/src/ocean.js create mode 100644 pirate-ship/src/ship.js create mode 100644 pirate-ship/src/sky.js create mode 100644 pirate-ship/src/vegetation.js diff --git a/pirate-ship/index.html b/pirate-ship/index.html new file mode 100644 index 0000000..6344df0 --- /dev/null +++ b/pirate-ship/index.html @@ -0,0 +1,198 @@ + + + + + + Pirate Cove — Sail the Jungle Isles + + + + + + + +
+
+
Hoisting the colours…
+
+ +
+ ☠ Pirate Cove +
SAIL THE JUNGLE ISLES
+
+ +
+

☠ Pirate Cove

+

A cove of jungle isles, a ship of your own, and a fair wind.

+

W to make sail — A/D to steer

+
+ +
+
Wind
+ +
+ +
+
+
Speed
+
0.0 kn
+
+
+
Heading
+
N 000°
+
+
+
Sails
+
+ ⚓ Anchored + + + +
+
+
+ ⚓ anchor down + +
+
+ +
+ W/S more / less sail  ·  A/D rudder
+ Space anchor  ·  R reset  ·  H hide this
+ drag to look · scroll to zoom · sail with the wind for speed +
+ + + + diff --git a/pirate-ship/src/controls.js b/pirate-ship/src/controls.js new file mode 100644 index 0000000..9d77e37 --- /dev/null +++ b/pirate-ship/src/controls.js @@ -0,0 +1,131 @@ +// --------------------------------------------------------------------------- +// controls.js — keyboard sailing controls + third-person chase camera with +// mouse orbit and wheel zoom. The camera auto-settles behind the ship. +// --------------------------------------------------------------------------- + +import * as THREE from 'three'; +import { heightAt } from './waves.js'; +import { terrainHeightAt } from './islandField.js'; + +export class Controls { + constructor(body, camera, dom, hud) { + this.body = body; + this.camera = camera; + this.hud = hud; + + this.yaw = body.heading + Math.PI; // camera behind the ship + this.pitch = 0.30; + this.dist = 34; + this.lastDragTime = -10; + this.keys = new Set(); + + this._camTarget = new THREE.Vector3(); + this._desired = new THREE.Vector3(); + + window.addEventListener('keydown', (e) => { + if (e.repeat) { + this.keys.add(e.code); + return; + } + switch (e.code) { + case 'KeyW': + case 'ArrowUp': + body.changeSail(+1); + hud?.flashSail(); + break; + case 'KeyS': + case 'ArrowDown': + body.changeSail(-1); + hud?.flashSail(); + break; + case 'Space': + body.toggleAnchor(); + e.preventDefault(); + break; + case 'KeyR': + body.reset(); + break; + case 'KeyH': + hud?.toggleHelp(); + break; + } + this.keys.add(e.code); + hud?.dismissIntro(); + }); + window.addEventListener('keyup', (e) => this.keys.delete(e.code)); + window.addEventListener('blur', () => this.keys.clear()); + + // mouse orbit + let dragging = false; + let lastX = 0; + let lastY = 0; + dom.addEventListener('pointerdown', (e) => { + dragging = true; + lastX = e.clientX; + lastY = e.clientY; + hud?.dismissIntro(); + }); + window.addEventListener('pointermove', (e) => { + if (!dragging) return; + this.yaw -= (e.clientX - lastX) * 0.0052; + this.pitch = THREE.MathUtils.clamp(this.pitch + (e.clientY - lastY) * 0.0042, 0.04, 1.25); + lastX = e.clientX; + lastY = e.clientY; + this.lastDragTime = performance.now() / 1000; + }); + window.addEventListener('pointerup', () => (dragging = false)); + dom.addEventListener( + 'wheel', + (e) => { + e.preventDefault(); + this.dist = THREE.MathUtils.clamp(this.dist * (e.deltaY > 0 ? 1.09 : 0.92), 12, 80); + }, + { passive: false } + ); + } + + /** held-key rudder input -> physics */ + applyInput() { + const left = this.keys.has('KeyA') || this.keys.has('ArrowLeft'); + const right = this.keys.has('KeyD') || this.keys.has('ArrowRight'); + this.body.rudderInput = (right ? 1 : 0) - (left ? 1 : 0); + } + + updateCamera(dt, t) { + const body = this.body; + const now = performance.now() / 1000; + + // settle behind the ship when the mouse has been idle + if (now - this.lastDragTime > 2.4) { + const targetYaw = body.heading + Math.PI; + let d = targetYaw - this.yaw; + while (d > Math.PI) d -= Math.PI * 2; + while (d < -Math.PI) d += Math.PI * 2; + this.yaw += d * Math.min(1, dt * 1.4); + } + + const cp = Math.cos(this.pitch); + this._camTarget.set(body.pos.x, body.pos.y + 3.4, body.pos.z); + this._desired.set( + this._camTarget.x + Math.sin(this.yaw) * cp * this.dist, + this._camTarget.y + Math.sin(this.pitch) * this.dist, + this._camTarget.z + Math.cos(this.yaw) * cp * this.dist + ); + + // keep the camera above the waves and island terrain + const waterY = heightAt(this._desired.x, this._desired.z, t); + const groundY = terrainHeightAt(this._desired.x, this._desired.z); + this._desired.y = Math.max(this._desired.y, waterY + 1.6, groundY + 2.5); + + // critically-damped style smoothing + const k = 1 - Math.exp(-dt * 7.5); + this.camera.position.lerp(this._desired, k); + this.camera.lookAt(this._camTarget); + + // subtle speed kick + const speed = Math.abs(body.speed); + const targetFov = 55 + Math.min(speed, 14) * 0.5; + this.camera.fov += (targetFov - this.camera.fov) * Math.min(1, dt * 2); + this.camera.updateProjectionMatrix(); + } +} diff --git a/pirate-ship/src/effects.js b/pirate-ship/src/effects.js new file mode 100644 index 0000000..4d5e530 --- /dev/null +++ b/pirate-ship/src/effects.js @@ -0,0 +1,340 @@ +// --------------------------------------------------------------------------- +// effects.js — stern wake ribbon, bow spray, contact "blob" shadow on the +// water (custom water shader can't receive shadow maps), and ambient birds. +// All effects ride the real wave surface via waves.sampleAt. +// --------------------------------------------------------------------------- + +import * as THREE from 'three'; +import { heightAt } from './waves.js'; + +// --- wake ribbon ------------------------------------------------------------- + +const WAKE_MAX = 72; +const WAKE_LIFE = 7.0; + +const WAKE_FRAG = /* glsl */ ` +varying float vAlpha; +varying vec2 vUv; +uniform float uTime; +void main() { + // soft edges + streaky procedural breakup + float edge = smoothstep(0.0, 0.32, vUv.x) * smoothstep(1.0, 0.68, vUv.x); + float streak = 0.72 + 0.28 * sin(vUv.y * 40.0 + vUv.x * 6.0); + float a = vAlpha * edge * streak; + gl_FragColor = vec4(0.92, 0.97, 1.0, a); + #include + #include +} +`; + +const WAKE_VERT = /* glsl */ ` +attribute float aAlpha; +varying float vAlpha; +varying vec2 vUv; +void main() { + vAlpha = aAlpha; + vUv = uv; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); +} +`; + +class WakeRibbon { + constructor(scene) { + this.points = []; // { x, z, birth, dirX, dirZ, w } + const geo = new THREE.BufferGeometry(); + this.posAttr = new THREE.BufferAttribute(new Float32Array(WAKE_MAX * 2 * 3), 3); + this.alphaAttr = new THREE.BufferAttribute(new Float32Array(WAKE_MAX * 2), 1); + this.uvAttr = new THREE.BufferAttribute(new Float32Array(WAKE_MAX * 2 * 2), 2); + geo.setAttribute('position', this.posAttr); + geo.setAttribute('aAlpha', this.alphaAttr); + geo.setAttribute('uv', this.uvAttr); + const idx = []; + for (let i = 0; i < WAKE_MAX - 1; i++) { + const a = i * 2; + idx.push(a, a + 1, a + 2, a + 1, a + 3, a + 2); + } + geo.setIndex(idx); + geo.setDrawRange(0, 0); + this.geo = geo; + this.mesh = new THREE.Mesh( + geo, + new THREE.ShaderMaterial({ + vertexShader: WAKE_VERT, + fragmentShader: WAKE_FRAG, + uniforms: { uTime: { value: 0 } }, + transparent: true, + depthWrite: false, + side: THREE.DoubleSide, + }) + ); + this.mesh.frustumCulled = false; + this.mesh.renderOrder = 2; + scene.add(this.mesh); + } + + update(body, t) { + const speed = Math.abs(body.speed); + // stern position in world space + const sx = body.pos.x - body._fwd.x * 14.5; + const sz = body.pos.z - body._fwd.z * 14.5; + const last = this.points[this.points.length - 1]; + if (speed > 1.2 && (!last || Math.hypot(sx - last.x, sz - last.z) > 2.2)) { + this.points.push({ + x: sx, + z: sz, + birth: t, + dirX: body._right.x, + dirZ: body._right.z, + str: Math.min(speed / 9, 1), + }); + if (this.points.length > WAKE_MAX) this.points.shift(); + } + // drop expired + while (this.points.length && t - this.points[0].birth > WAKE_LIFE) this.points.shift(); + + const n = this.points.length; + for (let i = 0; i < n; i++) { + const p = this.points[i]; + const age = (t - p.birth) / WAKE_LIFE; + const w = (1.6 + age * 7.5) * 0.5; + const y = heightAt(p.x, p.z, t) + 0.1; + this.posAttr.setXYZ(i * 2, p.x - p.dirX * w, y, p.z - p.dirZ * w); + this.posAttr.setXYZ(i * 2 + 1, p.x + p.dirX * w, y, p.z + p.dirZ * w); + const a = (1 - age) * 0.55 * p.str; + this.alphaAttr.setX(i * 2, a); + this.alphaAttr.setX(i * 2 + 1, a); + this.uvAttr.setXY(i * 2, 0, i * 0.35); + this.uvAttr.setXY(i * 2 + 1, 1, i * 0.35); + } + this.posAttr.needsUpdate = true; + this.alphaAttr.needsUpdate = true; + this.uvAttr.needsUpdate = true; + this.geo.setDrawRange(0, Math.max(0, (n - 1) * 6)); + } +} + +// --- bow spray ---------------------------------------------------------------- + +const SPRAY_N = 220; + +class BowSpray { + constructor(scene) { + this.parts = []; + for (let i = 0; i < SPRAY_N; i++) { + this.parts.push({ x: 0, y: -50, z: 0, vx: 0, vy: 0, vz: 0, life: 0, age: 1 }); + } + this.cursor = 0; + const geo = new THREE.BufferGeometry(); + this.posAttr = new THREE.BufferAttribute(new Float32Array(SPRAY_N * 3), 3); + geo.setAttribute('position', this.posAttr); + this.mesh = new THREE.Points( + geo, + new THREE.PointsMaterial({ + color: 0xeef7fb, + size: 0.55, + transparent: true, + opacity: 0.7, + depthWrite: false, + sizeAttenuation: true, + }) + ); + this.mesh.frustumCulled = false; + this.mesh.renderOrder = 2; + scene.add(this.mesh); + } + + update(body, t, dt) { + const speed = Math.abs(body.speed); + // spawn at the bow shoulders proportional to speed + if (speed > 4) { + const count = Math.min(4, Math.floor(speed * 0.45)); + for (let k = 0; k < count; k++) { + const p = this.parts[this.cursor]; + this.cursor = (this.cursor + 1) % SPRAY_N; + const side = Math.random() > 0.5 ? 1 : -1; + const bx = body.pos.x + body._fwd.x * 13.2 + body._right.x * side * 1.6; + const bz = body.pos.z + body._fwd.z * 13.2 + body._right.z * side * 1.6; + p.x = bx; + p.z = bz; + p.y = heightAt(bx, bz, t) + 0.3; + p.vx = body.vel.x * 0.55 + body._right.x * side * (1.2 + Math.random() * 1.6); + p.vz = body.vel.z * 0.55 + body._right.z * side * (1.2 + Math.random() * 1.6); + p.vy = 1.8 + Math.random() * 2.4 * Math.min(speed / 9, 1.2); + p.life = 0.7 + Math.random() * 0.7; + p.age = 0; + } + } + for (let i = 0; i < SPRAY_N; i++) { + const p = this.parts[i]; + if (p.age >= p.life) { + this.posAttr.setXYZ(i, 0, -50, 0); + continue; + } + p.age += dt; + p.vy -= 9.81 * dt; + p.x += p.vx * dt; + p.y += p.vy * dt; + p.z += p.vz * dt; + this.posAttr.setXYZ(i, p.x, p.y, p.z); + } + this.posAttr.needsUpdate = true; + } +} + +// --- blob shadow under the ship ----------------------------------------------- + +class BlobShadow { + constructor(scene) { + const NX = 10; + const NZ = 5; + this.NX = NX; + this.NZ = NZ; + const geo = new THREE.BufferGeometry(); + this.posAttr = new THREE.BufferAttribute(new Float32Array((NX + 1) * (NZ + 1) * 3), 3); + const alpha = new Float32Array((NX + 1) * (NZ + 1)); + const idx = []; + for (let j = 0; j <= NZ; j++) { + for (let i = 0; i <= NX; i++) { + const u = i / NX - 0.5; + const v = j / NZ - 0.5; + const d = Math.hypot(u * 2, v * 2); + alpha[j * (NX + 1) + i] = Math.max(0, 1 - d) * 0.34; + } + } + for (let j = 0; j < NZ; j++) { + for (let i = 0; i < NX; i++) { + const a = j * (NX + 1) + i; + const b = a + 1; + const c = a + NX + 1; + const d = c + 1; + idx.push(a, c, b, b, c, d); + } + } + geo.setAttribute('position', this.posAttr); + geo.setAttribute('aAlpha', new THREE.BufferAttribute(alpha, 1)); + geo.setIndex(idx); + this.mesh = new THREE.Mesh( + geo, + new THREE.ShaderMaterial({ + vertexShader: /* glsl */ ` + attribute float aAlpha; + varying float vA; + void main() { + vA = aAlpha; + gl_Position = projectionMatrix * viewMatrix * vec4(position, 1.0); + }`, + fragmentShader: /* glsl */ ` + varying float vA; + void main() { gl_FragColor = vec4(0.0, 0.05, 0.09, vA); }`, + transparent: true, + depthWrite: false, + side: THREE.DoubleSide, + }) + ); + this.mesh.frustumCulled = false; + this.mesh.renderOrder = 1; + scene.add(this.mesh); + } + + update(body, t) { + const fx = body._fwd.x; + const fz = body._fwd.z; + const rx = body._right.x; + const rz = body._right.z; + const L = 33; + const W = 12; + for (let j = 0; j <= this.NZ; j++) { + for (let i = 0; i <= this.NX; i++) { + const u = (i / this.NX - 0.5) * L; + const v = (j / this.NZ - 0.5) * W; + const x = body.pos.x + fx * u + rx * v; + const z = body.pos.z + fz * u + rz * v; + const y = heightAt(x, z, t) + 0.14; + this.posAttr.setXYZ(j * (this.NX + 1) + i, x, y, z); + } + } + this.posAttr.needsUpdate = true; + } +} + +// --- birds --------------------------------------------------------------------- + +const BIRD_N = 10; + +class Birds { + constructor(scene, timeUniform) { + // simple gull: two wing triangles + const verts = new Float32Array([ + 0, 0, 0.55, 0, 0, -0.4, -1.6, 0.18, 0, // left wing + 0, 0, 0.55, 1.6, 0.18, 0, 0, 0, -0.4, // right wing + ]); + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', new THREE.BufferAttribute(verts, 3)); + const mat = new THREE.MeshBasicMaterial({ color: 0x303a42, side: THREE.DoubleSide }); + mat.onBeforeCompile = (shader) => { + shader.uniforms.uTime = timeUniform; + shader.vertexShader = + 'uniform float uTime;\n' + + shader.vertexShader.replace( + '#include ', + `#include + { + float phase = instanceMatrix[3][0] * 0.5 + instanceMatrix[3][2]; + transformed.y += sin(uTime * 7.0 + phase) * abs(transformed.x) * 0.55; + }` + ); + }; + this.mesh = new THREE.InstancedMesh(geo, mat, BIRD_N); + this.mesh.frustumCulled = false; + this.birds = []; + for (let i = 0; i < BIRD_N; i++) { + const home = i < 5 ? { x: -250, z: 395 } : { x: 470, z: -290 }; + this.birds.push({ + home, + r: 60 + Math.random() * 120, + h: 30 + Math.random() * 35, + a: Math.random() * Math.PI * 2, + speed: 0.25 + Math.random() * 0.2, + scale: 1.6 + Math.random() * 1.2, + }); + } + this._m = new THREE.Matrix4(); + this._q = new THREE.Quaternion(); + this._e = new THREE.Euler(); + this._p = new THREE.Vector3(); + this._s = new THREE.Vector3(); + scene.add(this.mesh); + } + + update(dt) { + for (let i = 0; i < BIRD_N; i++) { + const b = this.birds[i]; + b.a += b.speed * dt; + const x = b.home.x + Math.cos(b.a) * b.r; + const z = b.home.z + Math.sin(b.a) * b.r; + this._e.set(0, -b.a, 0); + this._q.setFromEuler(this._e); + this._p.set(x, b.h + Math.sin(b.a * 3) * 4, z); + this._s.setScalar(b.scale); + this._m.compose(this._p, this._q, this._s); + this.mesh.setMatrixAt(i, this._m); + } + this.mesh.instanceMatrix.needsUpdate = true; + } +} + +export class Effects { + constructor(scene, timeUniform) { + this.wake = new WakeRibbon(scene); + this.spray = new BowSpray(scene); + this.blob = new BlobShadow(scene); + this.birds = new Birds(scene, timeUniform); + } + + update(body, t, dt) { + this.wake.update(body, t); + this.spray.update(body, t, dt); + this.blob.update(body, t); + this.birds.update(dt); + } +} diff --git a/pirate-ship/src/hud.js b/pirate-ship/src/hud.js new file mode 100644 index 0000000..a071c99 --- /dev/null +++ b/pirate-ship/src/hud.js @@ -0,0 +1,69 @@ +// --------------------------------------------------------------------------- +// hud.js — DOM heads-up display: knots, heading, sail setting, wind, hints. +// --------------------------------------------------------------------------- + +import { SAIL_SETTINGS } from './physics.js'; +import { WIND } from './waves.js'; + +const CARDINALS = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']; + +export class Hud { + constructor() { + this.elSpeed = document.getElementById('hud-speed'); + this.elHeading = document.getElementById('hud-heading'); + this.elSail = document.getElementById('hud-sail'); + this.elPips = [...document.querySelectorAll('.sail-pip')]; + this.elWindArrow = document.getElementById('wind-arrow'); + this.elAnchor = document.getElementById('hud-anchor'); + this.elAground = document.getElementById('hud-aground'); + this.elHelp = document.getElementById('help-card'); + this.elIntro = document.getElementById('intro'); + this._accum = 0; + this._sailFlash = 0; + } + + dismissIntro() { + if (this.elIntro && !this.elIntro.classList.contains('hidden')) { + this.elIntro.classList.add('hidden'); + } + } + + toggleHelp() { + this.elHelp?.classList.toggle('hidden'); + } + + flashSail() { + this._sailFlash = 0.8; + this.elSail?.classList.add('flash'); + } + + update(body, camYaw, dt) { + this._accum += dt; + this._sailFlash -= dt; + if (this._sailFlash < 0) this.elSail?.classList.remove('flash'); + if (this._accum < 0.12) return; // ~8 Hz is plenty for DOM + this._accum = 0; + + const knots = Math.abs(body.speed) * 1.94384; + this.elSpeed.textContent = knots.toFixed(1); + + let deg = (body.heading * 180) / Math.PI; + deg = ((deg % 360) + 360) % 360; + const card = CARDINALS[Math.round(deg / 45) % 8]; + this.elHeading.textContent = `${card} ${deg.toFixed(0).padStart(3, '0')}°`; + + const sail = body.anchored ? SAIL_SETTINGS[0] : body.sail; + this.elSail.textContent = body.anchored ? '⚓ Anchored' : sail.name; + this.elPips.forEach((pip, i) => { + pip.classList.toggle('on', !body.anchored && i < body.sailIndex); + }); + + this.elAnchor.classList.toggle('hidden', !body.anchored); + this.elAground.classList.toggle('hidden', !body.aground); + + // wind arrow is drawn relative to the camera view direction + const windAngle = Math.atan2(WIND.dirX, WIND.dirZ); + const rel = windAngle - camYaw + Math.PI; + this.elWindArrow.style.transform = `rotate(${(-rel * 180) / Math.PI}deg)`; + } +} diff --git a/pirate-ship/src/islands.js b/pirate-ship/src/islands.js new file mode 100644 index 0000000..e7efb4a --- /dev/null +++ b/pirate-ship/src/islands.js @@ -0,0 +1,118 @@ +// --------------------------------------------------------------------------- +// islands.js — low-poly island terrain meshes built from the shared island +// field (islandField.js), with vertex-colour terrain bands: +// sand -> jungle greens -> rock on steep slopes. +// --------------------------------------------------------------------------- + +import * as THREE from 'three'; +import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js'; +import { ISLANDS, islandHeightAt, terrainHeightAt } from './islandField.js'; +import { createFbm2D } from './noise.js'; + +const C_SAND = new THREE.Color(0xe6d49c); +const C_SAND_WET = new THREE.Color(0xc5ad7c); +const C_GRASS_A = new THREE.Color(0x2e7136); +const C_GRASS_B = new THREE.Color(0x55a047); +const C_ROCK = new THREE.Color(0x8a8273); +const C_ROCK_DARK = new THREE.Color(0x6e6759); + +export function buildIslands(scene) { + const fbm = createFbm2D(9001, 4); + const colorFbm = createFbm2D(5005, 3); + const parts = []; + + for (const isl of ISLANDS) { + const segs = Math.round(THREE.MathUtils.clamp(isl.r * 0.55, 40, 92)); + const rings = Math.round(THREE.MathUtils.clamp(isl.r * 0.26, 18, 48)); + const maxR = isl.r * 1.3; // extend below sea level so the shoreline is sealed + const verts = []; + const cols = []; + const idx = []; + + for (let i = 0; i <= rings; i++) { + const tr = i / rings; + const rad = Math.pow(tr, 0.85) * maxR; // denser rings near the summit + for (let j = 0; j < segs; j++) { + const a = (j / segs) * Math.PI * 2; + const x = isl.cx + Math.cos(a) * rad; + const z = isl.cz + Math.sin(a) * rad; + let y = islandHeightAt(isl, x, z); + // interior detail bumps — only well above the shoreline so the + // physics/shader field stays exact where it matters (the beach) + if (y > 2.0) { + const inland = Math.min((y - 2.0) / 6.0, 1); + y += fbm(x * 0.02, z * 0.02) * 4.2 * inland; + y += fbm(x * 0.07, z * 0.07) * 1.3 * inland; + } + verts.push(x, y, z); + cols.push(0, 0, 0); // filled after normals are known + } + } + for (let i = 0; i < rings; i++) { + for (let j = 0; j < segs; j++) { + const j1 = (j + 1) % segs; + const a = i * segs + j; + const b = i * segs + j1; + const c = (i + 1) * segs + j; + const d = (i + 1) * segs + j1; + if (i === 0) { + // ring 0 is the degenerate centre: fan triangles only + idx.push(b, d, c); + } else { + idx.push(a, b, c, b, d, c); + } + } + } + + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3)); + geo.setIndex(idx); + geo.computeVertexNormals(); + + // colour by height + slope + const pos = geo.getAttribute('position'); + const nor = geo.getAttribute('normal'); + const col = new Float32Array(pos.count * 3); + const c = new THREE.Color(); + for (let v = 0; v < pos.count; v++) { + const x = pos.getX(v); + const y = pos.getY(v); + const z = pos.getZ(v); + const slope = 1 - nor.getY(v); // 0 flat .. 1 vertical + const n = colorFbm(x * 0.045, z * 0.045) * 0.5 + 0.5; + + if (y < 0.45) c.copy(C_SAND_WET); + else if (y < 1.7) c.copy(C_SAND).lerp(C_SAND_WET, Math.max(0, (1.0 - y) * 0.4)); + else { + c.copy(C_GRASS_A).lerp(C_GRASS_B, n); + // blend sand->grass across the 1.7..2.6 band + if (y < 2.6) c.lerp(C_SAND, (2.6 - y) / 0.9); + } + if (slope > 0.38 && y > 1.2) { + c.copy(C_ROCK).lerp(C_ROCK_DARK, n); + } else if (slope > 0.3 && y > 1.2) { + c.lerp(C_ROCK, (slope - 0.3) / 0.08 * 0.7); + } + col[v * 3] = c.r; + col[v * 3 + 1] = c.g; + col[v * 3 + 2] = c.b; + } + geo.setAttribute('color', new THREE.BufferAttribute(col, 3)); + parts.push(geo); + } + + const merged = mergeGeometries(parts); + const mat = new THREE.MeshStandardMaterial({ + vertexColors: true, + flatShading: true, + roughness: 1.0, + metalness: 0.0, + }); + const mesh = new THREE.Mesh(merged, mat); + mesh.castShadow = true; + mesh.receiveShadow = true; + scene.add(mesh); + return mesh; +} + +export { terrainHeightAt }; diff --git a/pirate-ship/src/main.js b/pirate-ship/src/main.js new file mode 100644 index 0000000..1cc0afe --- /dev/null +++ b/pirate-ship/src/main.js @@ -0,0 +1,130 @@ +// --------------------------------------------------------------------------- +// main.js — bootstrap + game loop. +// Fixed 60 Hz physics with an accumulator; rendering at display rate. +// --------------------------------------------------------------------------- + +import * as THREE from 'three'; +import { ShipPhysics } from './physics.js'; +import { PirateShip } from './ship.js'; +import { Ocean } from './ocean.js'; +import { SkyEnv } from './sky.js'; +import { buildIslands } from './islands.js'; +import { buildVegetation } from './vegetation.js'; +import { Effects } from './effects.js'; +import { Controls } from './controls.js'; +import { Hud } from './hud.js'; + +const FIXED_DT = 1 / 60; + +const canvas = document.getElementById('game'); +const renderer = new THREE.WebGLRenderer({ + canvas, + antialias: true, + powerPreference: 'high-performance', +}); +renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.6)); +renderer.setSize(window.innerWidth, window.innerHeight); +renderer.outputColorSpace = THREE.SRGBColorSpace; +renderer.toneMapping = THREE.ACESFilmicToneMapping; +renderer.toneMappingExposure = 1.08; +renderer.shadowMap.enabled = true; +renderer.shadowMap.type = THREE.PCFShadowMap; + +const scene = new THREE.Scene(); +const camera = new THREE.PerspectiveCamera(55, window.innerWidth / window.innerHeight, 0.5, 12000); +camera.position.set(120, 14, 90); + +// shared time uniform for swaying vegetation / flapping birds +const timeUniform = { value: 0 }; + +const env = new SkyEnv(scene, renderer); +const ocean = new Ocean(scene, env); +buildIslands(scene); +const veg = buildVegetation(scene, timeUniform); + +const body = new ShipPhysics(); +const ship = new PirateShip(scene); +const effects = new Effects(scene, timeUniform); +const hud = new Hud(); +const controls = new Controls(body, camera, canvas, hud); + +window.addEventListener('resize', () => { + camera.aspect = window.innerWidth / window.innerHeight; + camera.updateProjectionMatrix(); + renderer.setSize(window.innerWidth, window.innerHeight); +}); + +// --- game loop ---------------------------------------------------------------- + +let simTime = 0; +let accumulator = 0; +let last = performance.now(); +let fps = 60; + +function frame() { + requestAnimationFrame(frame); + // use performance.now() rather than the rAF timestamp: the latter can run + // on a virtualised clock (throttled/headless tabs) and lose real time + const now = performance.now(); + let dt = (now - last) / 1000; + last = now; + if (dt > 0.25) dt = 0.25; // tab was hidden — don't spiral + fps += (1 / Math.max(dt, 1e-4) - fps) * 0.05; + + controls.applyInput(); + accumulator += dt; + while (accumulator >= FIXED_DT) { + simTime += FIXED_DT; + body.step(FIXED_DT, simTime); + accumulator -= FIXED_DT; + } + + timeUniform.value = simTime; + ship.update(body, simTime, dt); + controls.updateCamera(dt, simTime); + ocean.update(simTime, body.pos.x, body.pos.z, camera.position); + env.update(dt, ship.group.position, camera.position); + effects.update(body, simTime, dt); + hud.update(body, controls.yaw, dt); + + renderer.render(scene, camera); + + if (!window.__firstFrameDone) { + window.__firstFrameDone = true; + document.getElementById('loading')?.classList.add('hidden'); + } +} +requestAnimationFrame(frame); + +// --- debug / test hook ---------------------------------------------------------- + +window.__game = { + body, + camera, + renderer, + scene, + ocean, + vegetationCount: veg.count, + get fps() { + return fps; + }, + get state() { + return { + simTime, + x: body.pos.x, + y: body.pos.y, + z: body.pos.z, + heading: body.heading, + speed: body.speed, + sail: body.sailIndex, + anchored: body.anchored, + aground: body.aground, + fps, + drawCalls: renderer.info.render.calls, + triangles: renderer.info.render.triangles, + }; + }, + setSail(i) { + body.setSail(i); + }, +}; diff --git a/pirate-ship/src/ocean.js b/pirate-ship/src/ocean.js new file mode 100644 index 0000000..a13a253 --- /dev/null +++ b/pirate-ship/src/ocean.js @@ -0,0 +1,234 @@ +// --------------------------------------------------------------------------- +// ocean.js — ocean surface mesh + shader. +// +// Geometry: ONE radial grid centred on the ship — fine cells near the ship, +// geometrically growing rings out to the horizon. No seams, no LOD popping, +// one draw call. The shader displaces vertices with the same Gerstner sum the +// physics samples (waves.js is the single source of truth). +// --------------------------------------------------------------------------- + +import * as THREE from 'three'; +import { GEO_WAVES, DETAIL_WAVES, packWaves, GERSTNER_GLSL } from './waves.js'; +import { packIslands, ISLAND_GLSL, SEA_FLOOR_DEPTH, BEACH_SLOPE } from './islandField.js'; + +const RINGS = 132; +const SEGS = 168; +const INNER_CELL = 1.9; // metres, first ring spacing +const GROWTH = 1.038; // geometric ring growth (reaches ~6.8 km, well past the fog) + +function buildRadialGrid() { + const radii = [0]; + let r = 0; + let step = INNER_CELL; + for (let i = 0; i < RINGS; i++) { + r += step; + step *= GROWTH; + radii.push(r); + } + const verts = []; + for (let i = 0; i < radii.length; i++) { + for (let j = 0; j < SEGS; j++) { + const a = (j / SEGS) * Math.PI * 2; + verts.push(Math.cos(a) * radii[i], 0, Math.sin(a) * radii[i]); + } + } + const idx = []; + for (let i = 0; i < radii.length - 1; i++) { + for (let j = 0; j < SEGS; j++) { + const j1 = (j + 1) % SEGS; + const a = i * SEGS + j; + const b = i * SEGS + j1; + const c = (i + 1) * SEGS + j; + const d = (i + 1) * SEGS + j1; + idx.push(a, b, c, b, d, c); + } + } + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3)); + geo.setIndex(idx); + return geo; +} + +const VERT = /* glsl */ ` +#define NUM_GEO_WAVES ${GEO_WAVES.length} +uniform float uTime; +uniform vec3 uCamPos; +uniform vec4 uWaveA[NUM_GEO_WAVES]; +uniform vec4 uWaveB[NUM_GEO_WAVES]; + +varying vec3 vWorld; +varying vec3 vNormal; +varying float vCrest; +varying float vFade; +varying float vDist; + +${GERSTNER_GLSL} + +void main() { + vec3 wp = (modelMatrix * vec4(position, 1.0)).xyz; + float dist = distance(wp.xz, uCamPos.xz); + vDist = dist; + // overall fade used by foam/detail in the fragment stage + vFade = 1.0 - smoothstep(380.0, 2300.0, dist); + WaveOut w = gerstner(wp.xz, uTime, uWaveA, uWaveB, dist); + vec3 displaced = wp + w.disp; + vWorld = displaced; + vNormal = w.normal; + vCrest = w.crest; + gl_Position = projectionMatrix * viewMatrix * vec4(displaced, 1.0); +} +`; + +const FRAG = /* glsl */ ` +precision highp float; +#define NUM_ISLANDS ${packIslands().count} +#define NUM_DETAIL_WAVES ${DETAIL_WAVES.length} +#define SEA_FLOOR_DEPTH ${SEA_FLOOR_DEPTH.toFixed(1)} +#define BEACH_SLOPE ${BEACH_SLOPE} + +uniform float uTime; +uniform vec3 uCamPos; +uniform vec3 uSunDir; +uniform vec3 uSunColor; +uniform vec3 uDeepColor; +uniform vec3 uShallowColor; +uniform vec3 uSssColor; +uniform vec3 uSkyZenith; +uniform vec3 uSkyHorizon; +uniform vec3 uFogColor; +uniform float uFogDensity; +uniform vec4 uDetailA[NUM_DETAIL_WAVES]; +uniform vec4 uDetailB[NUM_DETAIL_WAVES]; +uniform vec4 uIslA[NUM_ISLANDS]; +uniform vec4 uIslB[NUM_ISLANDS]; +uniform vec4 uIslC[NUM_ISLANDS]; + +varying vec3 vWorld; +varying vec3 vNormal; +varying float vCrest; +varying float vFade; +varying float vDist; + +${ISLAND_GLSL} + +void main() { + // --- normal: geometry normal + tiny per-pixel ripples for sparkle. + // Detail ripples fade fast with distance (they'd just alias out there). + vec3 n = vNormal; + float detailFade = 1.0 - smoothstep(45.0, 220.0, vDist); + for (int i = 0; i < NUM_DETAIL_WAVES; i++) { + vec2 D = uDetailA[i].xy; + float k = uDetailA[i].z; + float om = uDetailA[i].w; + float amp = uDetailB[i].x * detailFade; + float f = k * dot(D, vWorld.xz) - om * uTime + uDetailB[i].z; + n.xz -= D * k * amp * cos(f) * 0.9; + } + n = normalize(n); + + vec3 V = normalize(uCamPos - vWorld); + vec3 R = reflect(-V, n); + R.y = abs(R.y); // waves never reflect "below horizon" + + // --- water body colour from depth (terrain field mirrors the islands) + float terrain = terrainHeight(vWorld.xz, uIslA, uIslB, uIslC); + float depth = max(vWorld.y - terrain, 0.0); + float shallow = exp(-depth * 0.14); + vec3 base = mix(uDeepColor, uShallowColor, clamp(shallow, 0.0, 1.0)); + + // subtle subsurface glow on sun-facing wave flanks + float sss = pow(max(dot(V, vec3(-uSunDir.x, 0.0, -uSunDir.z)), 0.0), 2.0) + * clamp(vWorld.y * 0.45 + 0.35, 0.0, 1.0) * (1.0 - n.y) * 2.4; + base += uSssColor * sss * vFade; + + // --- sky reflection (matches the sky dome gradient) + sun + vec3 sky = mix(uSkyHorizon, uSkyZenith, pow(clamp(R.y, 0.0, 1.0), 0.6)); + float sunSpec = pow(max(dot(R, uSunDir), 0.0), 760.0) * 4.0 + + pow(max(dot(R, uSunDir), 0.0), 64.0) * 0.22; + sky += uSunColor * sunSpec; + + float fresnel = 0.025 + 0.975 * pow(1.0 - max(dot(n, V), 0.0), 5.0); + vec3 col = mix(base, sky, fresnel); + + // --- foam + float breakup = sin(vWorld.x * 1.45 + uTime * 1.9) * sin(vWorld.z * 1.62 - uTime * 1.6); + breakup = 0.65 + 0.35 * breakup; + float crestFoam = smoothstep(0.3, 0.72, vCrest / 0.28) * breakup; + + float wob = sin(uTime * 1.25 + vWorld.x * 0.11 + vWorld.z * 0.085) * 0.55; + float shoreFoam = (1.0 - smoothstep(0.0, 2.3 + wob, depth - 0.25)) + * (0.6 + 0.4 * sin(depth * 3.1 - uTime * 2.2)); + shoreFoam += smoothstep(0.75, 1.0, sin(depth * 1.9 - uTime * 1.45)) * + (1.0 - smoothstep(0.0, 6.5, depth)) * 0.5; + shoreFoam = clamp(shoreFoam, 0.0, 1.0) * step(0.001, depth); + + float foam = clamp(crestFoam * 0.75 + shoreFoam * 0.95, 0.0, 1.0) * vFade; + col = mix(col, vec3(0.96, 0.99, 1.0), foam * 0.85); + + // --- fog (matches scene FogExp2) + float fogDist = distance(uCamPos, vWorld); + float fogF = 1.0 - exp(-fogDist * fogDist * uFogDensity * uFogDensity); + col = mix(col, uFogColor, fogF); + + gl_FragColor = vec4(col, 1.0); + #include + #include +} +`; + +export class Ocean { + constructor(scene, env) { + const geoPack = packWaves(GEO_WAVES); + const detPack = packWaves(DETAIL_WAVES); + const isl = packIslands(); + + this.uniforms = { + uTime: { value: 0 }, + uCamPos: { value: new THREE.Vector3() }, + uWaveA: { value: toVec4Array(geoPack.a) }, + uWaveB: { value: toVec4Array(geoPack.b) }, + uDetailA: { value: toVec4Array(detPack.a) }, + uDetailB: { value: toVec4Array(detPack.b) }, + uIslA: { value: toVec4Array(isl.a) }, + uIslB: { value: toVec4Array(isl.b) }, + uIslC: { value: toVec4Array(isl.c) }, + uSunDir: { value: env.sunDir }, + uSunColor: { value: new THREE.Color(0xffe9c4) }, + uDeepColor: { value: new THREE.Color(0x07335c) }, + uShallowColor: { value: new THREE.Color(0x1ec3b4) }, + uSssColor: { value: new THREE.Color(0x14b89c) }, + uSkyZenith: { value: env.skyZenith }, + uSkyHorizon: { value: env.skyHorizon }, + uFogColor: { value: env.fogColor }, + uFogDensity: { value: env.fogDensity }, + }; + + this.material = new THREE.ShaderMaterial({ + vertexShader: VERT, + fragmentShader: FRAG, + uniforms: this.uniforms, + side: THREE.FrontSide, + }); + + this.mesh = new THREE.Mesh(buildRadialGrid(), this.material); + this.mesh.frustumCulled = false; // grid always surrounds the camera + this.mesh.matrixAutoUpdate = false; + scene.add(this.mesh); + } + + update(t, focusX, focusZ, camPos) { + this.uniforms.uTime.value = t; + this.uniforms.uCamPos.value.copy(camPos); + // follow the ship, snapped to the inner cell size to avoid vertex crawl + const s = INNER_CELL; + this.mesh.matrix.makeTranslation(Math.round(focusX / s) * s, 0, Math.round(focusZ / s) * s); + } +} + +function toVec4Array(flat) { + const arr = []; + for (let i = 0; i < flat.length; i += 4) { + arr.push(new THREE.Vector4(flat[i], flat[i + 1], flat[i + 2], flat[i + 3])); + } + return arr; +} diff --git a/pirate-ship/src/physics.js b/pirate-ship/src/physics.js index dbe8d46..556d706 100644 --- a/pirate-ship/src/physics.js +++ b/pirate-ship/src/physics.js @@ -46,8 +46,8 @@ export const TUNE = { dragLat2: 0.35, rudderMax: 0.45, // rad rudderRate: 1.6, // rad/s - rudderTorque: 1.7, - angDamp: { pitch: 50, roll: 13, yaw: 68 }, + rudderTorque: 2.1, + angDamp: { pitch: 50, roll: 13, yaw: 58 }, inertia: { pitch: 72, roll: 11, yaw: 78 }, heelTurn: 0.55, // outward heel while turning heelWind: 0.5, // heel from beam wind on sails diff --git a/pirate-ship/src/ship.js b/pirate-ship/src/ship.js new file mode 100644 index 0000000..cafd6ca --- /dev/null +++ b/pirate-ship/src/ship.js @@ -0,0 +1,391 @@ +// --------------------------------------------------------------------------- +// ship.js — procedural low-poly pirate galleon. +// Local frame matches physics.js: +x starboard, +y up, +z forward (bow). +// Origin = centre of mass (waterline-ish). Keel ~ -1.85, main deck ~ +2.2. +// --------------------------------------------------------------------------- + +import * as THREE from 'three'; +import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js'; + +// Hull stations: z along the ship, w = half beam, keel/deck heights. +const STATIONS = [ + { z: -13.8, w: 2.3, keel: -0.2, deck: 3.6 }, // transom + { z: -11.0, w: 3.2, keel: -1.35, deck: 3.0 }, + { z: -7.0, w: 3.7, keel: -1.7, deck: 2.45 }, + { z: -2.0, w: 4.0, keel: -1.85, deck: 2.2 }, + { z: 3.0, w: 3.85, keel: -1.8, deck: 2.2 }, + { z: 7.5, w: 3.3, keel: -1.65, deck: 2.45 }, + { z: 11.0, w: 2.3, keel: -1.3, deck: 2.9 }, + { z: 13.6, w: 0.45, keel: -0.55, deck: 3.5 }, // bow tip (rising sheer) +]; + +// Cross-section profile from keel (s=0) to bulwark top (s=1): x and y fractions. +const SECTION = [ + [0.0, 0.0], + [0.5, 0.1], + [0.88, 0.42], + [1.0, 0.78], + [0.97, 1.0], + [0.92, 1.18], // bulwark lip above deck line +]; + +function buildHullGeometry() { + const verts = []; + const idx = []; + const ringSize = SECTION.length * 2 - 1; // mirrored, sharing the keel point + + for (const st of STATIONS) { + const ring = []; + // port side, from bulwark down to keel + for (let i = SECTION.length - 1; i >= 0; i--) { + const [fx, fy] = SECTION[i]; + ring.push([-st.w * fx, st.keel + (st.deck - st.keel) * fy, st.z]); + } + // starboard side, keel up to bulwark (skip duplicated keel point) + for (let i = 1; i < SECTION.length; i++) { + const [fx, fy] = SECTION[i]; + ring.push([st.w * fx, st.keel + (st.deck - st.keel) * fy, st.z]); + } + for (const p of ring) verts.push(...p); + } + for (let s = 0; s < STATIONS.length - 1; s++) { + for (let i = 0; i < ringSize - 1; i++) { + const a = s * ringSize + i; + const b = a + 1; + const c = a + ringSize; + const d = b + ringSize; + idx.push(a, c, b, b, c, d); + } + } + // transom (stern cap) + const sternCenter = verts.length / 3; + const st0 = STATIONS[0]; + verts.push(0, (st0.keel + st0.deck) / 2, st0.z); + for (let i = 0; i < ringSize - 1; i++) { + idx.push(i, i + 1, sternCenter); + } + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3)); + geo.setIndex(idx); + const non = geo.toNonIndexed(); + non.computeVertexNormals(); + return non; +} + +function buildDeckGeometry() { + // deck surface with slight camber, inset from the bulwark + const verts = []; + const idx = []; + STATIONS.forEach((st) => { + const w = st.w * 0.93; + verts.push(-w, st.deck, st.z, 0, st.deck + 0.18, st.z, w, st.deck, st.z); + }); + for (let s = 0; s < STATIONS.length - 1; s++) { + const a = s * 3; + idx.push(a, a + 3, a + 1, a + 1, a + 3, a + 4, a + 1, a + 4, a + 2, a + 2, a + 4, a + 5); + } + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3)); + geo.setIndex(idx); + const non = geo.toNonIndexed(); + non.computeVertexNormals(); + return non; +} + +function cylinderBetween(r1, r2, from, to, radial = 6) { + const dir = new THREE.Vector3().subVectors(to, from); + const len = dir.length(); + const geo = new THREE.CylinderGeometry(r1, r2, len, radial); + geo.translate(0, len / 2, 0); + const quat = new THREE.Quaternion().setFromUnitVectors( + new THREE.Vector3(0, 1, 0), + dir.clone().normalize() + ); + geo.applyQuaternion(quat); + geo.translate(from.x, from.y, from.z); + return geo; +} + +function makeSquareSail(width, height, billow) { + // hangs from its top edge (origin); billows toward the bow (+z) + const geo = new THREE.PlaneGeometry(width, height, 8, 6); + geo.translate(0, -height / 2, 0); + const p = geo.getAttribute('position'); + for (let i = 0; i < p.count; i++) { + const u = p.getX(i) / width + 0.5; + const v = -p.getY(i) / height; // 0 top .. 1 bottom + p.setZ(i, Math.sin(Math.PI * u) * (0.25 + 0.75 * v) * billow); + // bottom corners pulled slightly inward (sheeted) + p.setX(i, p.getX(i) * (1 - v * 0.12)); + } + geo.computeVertexNormals(); + return geo; +} + +function makeJibSail(billow) { + // triangular staysail between bowsprit and foremast, subdivided fan + const A = new THREE.Vector3(0, 0.4, 6.4); // tack near bowsprit tip (local to group) + const B = new THREE.Vector3(0, 8.2, -3.2); // head up the foremast stay + const C = new THREE.Vector3(0, 0.6, -3.4); // clew at deck + const segs = 6; + const verts = []; + const idx = []; + for (let i = 0; i <= segs; i++) { + const t = i / segs; + const top = new THREE.Vector3().lerpVectors(A, B, t); + const bot = new THREE.Vector3().lerpVectors(A, C, t); + for (let j = 0; j <= 2; j++) { + const v = j / 2; + const pt = new THREE.Vector3().lerpVectors(top, bot, v); + pt.x += Math.sin(Math.PI * t) * Math.sin(Math.PI * v) * billow; + verts.push(pt.x, pt.y, pt.z); + } + } + for (let i = 0; i < segs; i++) { + for (let j = 0; j < 2; j++) { + const a = i * 3 + j; + const b = a + 3; + idx.push(a, b, a + 1, a + 1, b, b + 1); + } + } + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3)); + geo.setIndex(idx); + const non = geo.toNonIndexed(); + non.computeVertexNormals(); + return non; +} + +function makeFlagTexture() { + const c = document.createElement('canvas'); + c.width = 128; + c.height = 80; + const g = c.getContext('2d'); + g.fillStyle = '#14100e'; + g.fillRect(0, 0, 128, 80); + g.fillStyle = '#f2ead8'; + // skull + g.beginPath(); + g.arc(64, 32, 14, 0, Math.PI * 2); + g.fill(); + g.fillRect(57, 40, 14, 8); + // eyes + nose + g.fillStyle = '#14100e'; + g.beginPath(); + g.arc(58, 30, 3.4, 0, Math.PI * 2); + g.arc(70, 30, 3.4, 0, Math.PI * 2); + g.fill(); + g.beginPath(); + g.moveTo(64, 35); + g.lineTo(61, 40); + g.lineTo(67, 40); + g.fill(); + // crossbones + g.strokeStyle = '#f2ead8'; + g.lineWidth = 5; + g.lineCap = 'round'; + g.beginPath(); + g.moveTo(38, 56); + g.lineTo(90, 68); + g.moveTo(90, 56); + g.lineTo(38, 68); + g.stroke(); + const tex = new THREE.CanvasTexture(c); + tex.colorSpace = THREE.SRGBColorSpace; + return tex; +} + +export class PirateShip { + constructor(scene) { + this.group = new THREE.Group(); + + const matHull = new THREE.MeshStandardMaterial({ color: 0x53381f, roughness: 0.85, flatShading: true }); + const matWood = new THREE.MeshStandardMaterial({ color: 0x96704a, roughness: 0.9, flatShading: true }); + const matDark = new THREE.MeshStandardMaterial({ color: 0x3a2a19, roughness: 0.9, flatShading: true }); + const matGold = new THREE.MeshStandardMaterial({ color: 0xd8a93f, roughness: 0.45, metalness: 0.55 }); + this.matSail = new THREE.MeshStandardMaterial({ + color: 0xf0e6cf, + roughness: 0.9, + side: THREE.DoubleSide, + flatShading: true, + // faint warm glow fakes sunlight bleeding through the canvas + emissive: 0x9b8a66, + emissiveIntensity: 0.34, + }); + + // --- hull + deck + const hull = new THREE.Mesh(buildHullGeometry(), matHull); + const deck = new THREE.Mesh(buildDeckGeometry(), matWood); + + // --- superstructure, keel, gold trim + const woodParts = []; + const darkParts = []; + const goldParts = []; + + woodParts.push(new THREE.BoxGeometry(4.6, 1.7, 5.4).translate(0, 3.3, -10.6)); // quarterdeck cabin + woodParts.push(new THREE.BoxGeometry(3.2, 1.1, 3.6).translate(0, 3.0, 10.4)); // forecastle + darkParts.push(new THREE.BoxGeometry(0.5, 0.9, 15).translate(0, -2.0, -0.5)); // keel fin + darkParts.push(new THREE.BoxGeometry(0.4, 1.4, 0.9).translate(0, -1.6, -13.6)); // rudder + goldParts.push(new THREE.BoxGeometry(4.4, 0.28, 0.18).translate(0, 3.45, -13.95)); // stern trim + goldParts.push(new THREE.BoxGeometry(3.6, 0.2, 0.16).translate(0, 2.6, -14.0)); + + // cannons poking through the bulwarks + for (const side of [-1, 1]) { + for (const z of [-6.5, -2.5, 1.5, 5.5]) { + const g = new THREE.CylinderGeometry(0.13, 0.16, 1.5, 6) + .rotateZ(Math.PI / 2) + .translate(side * 3.55, 1.45, z); + darkParts.push(g); + } + } + + // --- masts & spars + const mastDefs = [ + { z: -1.0, base: 2.2, top: 18.2, r: 0.3 }, // main + { z: 7.5, base: 2.4, top: 15.0, r: 0.24 }, // fore + { z: -9.5, base: 3.8, top: 13.2, r: 0.2 }, // mizzen + ]; + for (const m of mastDefs) { + woodParts.push( + cylinderBetween(m.r * 0.55, m.r, new THREE.Vector3(0, m.base, m.z), new THREE.Vector3(0, m.top, m.z), 7) + ); + // crow's nest on the main mast + if (m.z === -1.0) { + woodParts.push(new THREE.CylinderGeometry(0.55, 0.42, 0.55, 8, 1, true).translate(0, 14.4, m.z)); + } + } + // bowsprit + woodParts.push( + cylinderBetween(0.09, 0.2, new THREE.Vector3(0, 3.4, 13.2), new THREE.Vector3(0, 6.6, 20.2), 6) + ); + + // yards (spars carrying the square sails) + const yards = [ + { z: -1.0, y: 13.4, w: 8.2 }, + { z: -1.0, y: 9.2, w: 10.4 }, + { z: 7.5, y: 11.4, w: 6.6 }, + { z: 7.5, y: 8.0, w: 8.4 }, + ]; + for (const yd of yards) { + woodParts.push( + new THREE.CylinderGeometry(0.09, 0.09, yd.w, 6).rotateZ(Math.PI / 2).translate(0, yd.y, yd.z) + ); + } + + const woodMesh = new THREE.Mesh(mergeGeometries(woodParts), matWood); + const darkMesh = new THREE.Mesh(mergeGeometries(darkParts), matDark); + const goldMesh = new THREE.Mesh(mergeGeometries(goldParts), matGold); + + // --- sails (separate meshes so they can furl with the sail setting) + this.sails = []; + const sailDefs = [ + { yard: yards[0], drop: 3.6, billow: 0.75 }, + { yard: yards[1], drop: 4.4, billow: 1.05 }, + { yard: yards[2], drop: 2.9, billow: 0.65 }, + { yard: yards[3], drop: 3.6, billow: 0.9 }, + ]; + for (const sd of sailDefs) { + const sail = new THREE.Mesh(makeSquareSail(sd.yard.w * 0.92, sd.drop, sd.billow), this.matSail); + sail.position.set(0, sd.yard.y - 0.12, sd.yard.z); + sail.castShadow = true; + this.sails.push(sail); + this.group.add(sail); + } + const jibGroup = new THREE.Group(); + jibGroup.position.set(0, 5.4, 13.4); + const jib = new THREE.Mesh(makeJibSail(0.9), this.matSail); + jib.castShadow = true; + jibGroup.add(jib); + this.jib = jib; + this.group.add(jibGroup); + + // --- flag + this.flagGeo = new THREE.PlaneGeometry(2.6, 1.5, 10, 4); + this.flagGeo.translate(1.3, 0, 0); // streams from the pole + this.flagBase = this.flagGeo.getAttribute('position').array.slice(); + const flag = new THREE.Mesh( + this.flagGeo, + new THREE.MeshStandardMaterial({ map: makeFlagTexture(), side: THREE.DoubleSide, roughness: 1 }) + ); + flag.position.set(0, 18.6, -1.0); + this.flag = flag; + this.group.add(flag); + + // --- stern lantern + const lantern = new THREE.Mesh( + new THREE.OctahedronGeometry(0.32), + new THREE.MeshStandardMaterial({ + color: 0xffd27a, + emissive: 0xffb84d, + emissiveIntensity: 1.4, + roughness: 0.3, + }) + ); + lantern.position.set(0, 4.6, -14.3); + this.group.add(lantern); + + // --- rigging + const rig = []; + const addLine = (a, b) => rig.push(a.x, a.y, a.z, b.x, b.y, b.z); + const v = (x, y, z) => new THREE.Vector3(x, y, z); + for (const m of mastDefs) { + const hTop = m.top - 0.4; + for (const side of [-1, 1]) { + for (const dz of [-1.6, 0, 1.6]) { + addLine(v(0, hTop * 0.82, m.z), v(side * 3.1, 2.6, m.z + dz)); + } + } + } + addLine(v(0, 18.0, -1), v(0, 6.5, 20.0)); // forestay to bowsprit + addLine(v(0, 14.8, 7.5), v(0, 6.5, 20.0)); + addLine(v(0, 18.0, -1), v(0, 14.8, 7.5)); // main->fore stay + addLine(v(0, 13.0, -9.5), v(0, 18.0, -1)); // mizzen->main + addLine(v(0, 13.0, -9.5), v(0, 3.7, -13.8)); // backstay + const rigGeo = new THREE.BufferGeometry(); + rigGeo.setAttribute('position', new THREE.Float32BufferAttribute(rig, 3)); + const rigging = new THREE.LineSegments( + rigGeo, + new THREE.LineBasicMaterial({ color: 0x1c130c, transparent: true, opacity: 0.85 }) + ); + + for (const mesh of [hull, deck, woodMesh, darkMesh, goldMesh]) { + mesh.castShadow = true; + mesh.receiveShadow = true; + this.group.add(mesh); + } + this.group.add(rigging); + + this._sailAmount = 0; + scene.add(this.group); + } + + /** Sync visuals with the physics body + animate sails/flag. */ + update(body, t, dt) { + this.group.position.set(body.pos.x, body.pos.y, body.pos.z); + this.group.quaternion.set(body.quat.x, body.quat.y, body.quat.z, body.quat.w); + + // sails furl/unfurl toward the current setting + const target = body.anchored ? 0 : body.sail.frac; + this._sailAmount += (target - this._sailAmount) * Math.min(1, dt * 1.8); + const a = this._sailAmount; + const sy = 0.08 + 0.92 * a; + for (const s of this.sails) { + s.scale.set(1, sy, 0.25 + 0.75 * a); + s.visible = a > 0.02; + } + this.jib.scale.setScalar(Math.max(0.001, a)); + this.jib.visible = a > 0.02; + + // flag streams in the wind (world-space wind direction -> local) + const windWorld = Math.atan2(1, 1); // wind blows toward +x+z + const heading = body.heading; + this.flag.rotation.y = windWorld - heading + Math.PI / 2; + const p = this.flagGeo.getAttribute('position'); + const base = this.flagBase; + for (let i = 0; i < p.count; i++) { + const bx = base[i * 3]; + p.setZ(i, Math.sin(bx * 2.4 - t * 8.0) * 0.16 * (bx / 2.6) + base[i * 3 + 2]); + } + p.needsUpdate = true; + } +} diff --git a/pirate-ship/src/sky.js b/pirate-ship/src/sky.js new file mode 100644 index 0000000..14b3619 --- /dev/null +++ b/pirate-ship/src/sky.js @@ -0,0 +1,208 @@ +// --------------------------------------------------------------------------- +// sky.js — sky dome, sun, clouds, lighting rig and fog. +// --------------------------------------------------------------------------- + +import * as THREE from 'three'; +import { WIND } from './waves.js'; + +const SKY_VERT = /* glsl */ ` +varying vec3 vDir; +void main() { + vDir = normalize(position); + vec4 wp = modelMatrix * vec4(position, 1.0); + gl_Position = projectionMatrix * viewMatrix * wp; +} +`; + +const SKY_FRAG = /* glsl */ ` +uniform vec3 uZenith; +uniform vec3 uMid; +uniform vec3 uHorizon; +uniform vec3 uSunDir; +uniform vec3 uSunColor; +varying vec3 vDir; +void main() { + vec3 d = normalize(vDir); + float h = clamp(d.y, 0.0, 1.0); + vec3 col = mix(uHorizon, uMid, smoothstep(0.0, 0.18, h)); + col = mix(col, uZenith, smoothstep(0.12, 0.65, h)); + float s = max(dot(d, uSunDir), 0.0); + col += uSunColor * (smoothstep(0.99955, 0.99985, s) * 3.0); // disc + col += uSunColor * pow(s, 240.0) * 0.55; // corona + col += uSunColor * pow(s, 9.0) * 0.16; // haze + gl_FragColor = vec4(col, 1.0); + #include + #include +} +`; + +// Camera-facing instanced cloud billboards (one draw call). +const CLOUD_VERT = /* glsl */ ` +attribute vec2 aScale; +varying vec2 vUv; +varying float vAlpha; +void main() { + vUv = uv; + vAlpha = aScale.y; + vec4 center = instanceMatrix * vec4(0.0, 0.0, 0.0, 1.0); + vec4 wc = modelMatrix * center; + vec3 camRight = vec3(viewMatrix[0][0], viewMatrix[1][0], viewMatrix[2][0]); + vec3 camUp = vec3(viewMatrix[0][1], viewMatrix[1][1], viewMatrix[2][1]); + vec3 wp = wc.xyz + (camRight * position.x + camUp * position.y) * aScale.x; + gl_Position = projectionMatrix * viewMatrix * vec4(wp, 1.0); +} +`; + +const CLOUD_FRAG = /* glsl */ ` +uniform sampler2D uMap; +varying vec2 vUv; +varying float vAlpha; +void main() { + vec4 tex = texture2D(uMap, vUv); + gl_FragColor = vec4(tex.rgb, tex.a * vAlpha); + if (gl_FragColor.a < 0.01) discard; + #include + #include +} +`; + +function makeCloudTexture() { + const c = document.createElement('canvas'); + c.width = c.height = 256; + const g = c.getContext('2d'); + g.clearRect(0, 0, 256, 256); + // a few overlapping soft blobs make a puffy cumulus silhouette + const blobs = [ + [128, 150, 70], [80, 160, 48], [180, 158, 52], [110, 120, 44], + [155, 118, 40], [60, 175, 30], [200, 178, 28], + ]; + for (const [x, y, r] of blobs) { + const grad = g.createRadialGradient(x, y, 0, x, y, r); + grad.addColorStop(0, 'rgba(255,255,255,0.85)'); + grad.addColorStop(0.65, 'rgba(250,252,255,0.45)'); + grad.addColorStop(1, 'rgba(245,250,255,0)'); + g.fillStyle = grad; + g.beginPath(); + g.arc(x, y, r, 0, Math.PI * 2); + g.fill(); + } + const tex = new THREE.CanvasTexture(c); + tex.colorSpace = THREE.SRGBColorSpace; + return tex; +} + +export class SkyEnv { + constructor(scene, renderer) { + this.sunDir = new THREE.Vector3(-0.42, 0.52, -0.74).normalize(); + this.skyZenith = new THREE.Color(0x2570bd); + this.skyHorizon = new THREE.Color(0xd6ecf2); + this.skyMid = new THREE.Color(0x7db8e3); + this.fogColor = new THREE.Color(0xc9e4ee); + this.fogDensity = 0.0004; + + scene.fog = new THREE.FogExp2(this.fogColor, this.fogDensity); + renderer.setClearColor(this.fogColor); + + // --- dome + this.domeUniforms = { + uZenith: { value: this.skyZenith }, + uMid: { value: this.skyMid }, + uHorizon: { value: this.skyHorizon }, + uSunDir: { value: this.sunDir }, + uSunColor: { value: new THREE.Color(0xfff0cf) }, + }; + const dome = new THREE.Mesh( + new THREE.SphereGeometry(5200, 32, 18), + new THREE.ShaderMaterial({ + vertexShader: SKY_VERT, + fragmentShader: SKY_FRAG, + uniforms: this.domeUniforms, + side: THREE.BackSide, + depthWrite: false, + fog: false, + }) + ); + dome.frustumCulled = false; + dome.renderOrder = -10; + this.dome = dome; + scene.add(dome); + + // --- sun light + shadows (shadow box follows the ship in update()) + const sun = new THREE.DirectionalLight(0xfff1d8, 2.7); + sun.castShadow = true; + sun.shadow.mapSize.set(2048, 2048); + sun.shadow.camera.near = 100; + sun.shadow.camera.far = 1400; + const ext = 120; + sun.shadow.camera.left = -ext; + sun.shadow.camera.right = ext; + sun.shadow.camera.top = ext; + sun.shadow.camera.bottom = -ext; + sun.shadow.bias = -0.0004; + sun.shadow.normalBias = 2.0; + this.sun = sun; + scene.add(sun); + scene.add(sun.target); + + this.hemi = new THREE.HemisphereLight(0xa8d8ff, 0x3f6b50, 0.95); + scene.add(this.hemi); + + // --- clouds + const cloudGeo = new THREE.PlaneGeometry(1, 0.55); + const N_CLOUDS = 18; + const cloudMat = new THREE.ShaderMaterial({ + vertexShader: CLOUD_VERT, + fragmentShader: CLOUD_FRAG, + uniforms: { uMap: { value: makeCloudTexture() } }, + transparent: true, + depthWrite: false, + fog: false, + }); + const clouds = new THREE.InstancedMesh(cloudGeo, cloudMat, N_CLOUDS); + const scales = new Float32Array(N_CLOUDS * 2); + const m = new THREE.Matrix4(); + this.cloudData = []; + for (let i = 0; i < N_CLOUDS; i++) { + const a = (i / N_CLOUDS) * Math.PI * 2 + (i % 3) * 0.41; + const r = 900 + ((i * 467) % 2100); + const x = Math.cos(a) * r; + const z = Math.sin(a) * r; + const y = 320 + ((i * 211) % 260); + m.makeTranslation(x, y, z); + clouds.setMatrixAt(i, m); + scales[i * 2] = 380 + ((i * 137) % 420); + scales[i * 2 + 1] = 0.5 + ((i * 31) % 40) / 100; + this.cloudData.push({ x, y, z }); + } + cloudGeo.setAttribute('aScale', new THREE.InstancedBufferAttribute(scales, 2)); + clouds.instanceMatrix.needsUpdate = true; + clouds.frustumCulled = false; + this.clouds = clouds; + this._cloudM = m; + scene.add(clouds); + } + + update(dt, focus, camPos) { + // dome + clouds follow the camera so the horizon never ends + this.dome.position.set(camPos.x, 0, camPos.z); + // shadow box follows the ship + this.sun.position.copy(focus).addScaledVector(this.sunDir, 700); + this.sun.target.position.copy(focus); + // clouds drift downwind + for (let i = 0; i < this.cloudData.length; i++) { + const c = this.cloudData[i]; + c.x += WIND.dirX * dt * 2.4; + c.z += WIND.dirZ * dt * 2.4; + // wrap around the camera + const dx = c.x - camPos.x; + const dz = c.z - camPos.z; + if (dx * dx + dz * dz > 3400 * 3400) { + c.x = camPos.x - dx * 0.96; + c.z = camPos.z - dz * 0.96; + } + this._cloudM.makeTranslation(c.x, c.y, c.z); + this.clouds.setMatrixAt(i, this._cloudM); + } + this.clouds.instanceMatrix.needsUpdate = true; + } +} diff --git a/pirate-ship/src/vegetation.js b/pirate-ship/src/vegetation.js new file mode 100644 index 0000000..2ef7f26 --- /dev/null +++ b/pirate-ship/src/vegetation.js @@ -0,0 +1,246 @@ +// --------------------------------------------------------------------------- +// vegetation.js — instanced jungle: palms, undergrowth and rocks scattered on +// the islands by height/slope. A handful of draw calls for hundreds of plants. +// --------------------------------------------------------------------------- + +import * as THREE from 'three'; +import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js'; +import { ISLANDS, terrainHeightAt, terrainGradientAt } from './islandField.js'; +import { mulberry32 } from './noise.js'; + +const TRUNK = new THREE.Color(0x7a5a38); +const TRUNK_D = new THREE.Color(0x5d4329); +const FROND_A = new THREE.Color(0x2f8f3c); +const FROND_B = new THREE.Color(0x57b54a); +const LEAF_DARK = new THREE.Color(0x256e30); +const COCONUT = new THREE.Color(0x4f3a22); +const ROCK_C = new THREE.Color(0x878073); + +function colorize(geo, color, color2 = null, axis = 'y') { + const pos = geo.getAttribute('position'); + const col = new Float32Array(pos.count * 3); + const c = new THREE.Color(); + let min = Infinity; + let max = -Infinity; + for (let i = 0; i < pos.count; i++) { + const v = axis === 'y' ? pos.getY(i) : pos.getX(i); + min = Math.min(min, v); + max = Math.max(max, v); + } + for (let i = 0; i < pos.count; i++) { + const v = axis === 'y' ? pos.getY(i) : pos.getX(i); + const t = (v - min) / (max - min || 1); + c.copy(color); + if (color2) c.lerp(color2, t); + col[i * 3] = c.r; + col[i * 3 + 1] = c.g; + col[i * 3 + 2] = c.b; + } + geo.setAttribute('color', new THREE.BufferAttribute(col, 3)); + return geo; +} + +/** A palm tree: bent trunk + radial drooping fronds + coconuts. */ +function makePalmGeometry(rand, height) { + const parts = []; + const bend = 1.2 + rand() * 1.6; + const bendDir = rand() * Math.PI * 2; + const bx = Math.cos(bendDir); + const bz = Math.sin(bendDir); + + // trunk: cylinder bent along a parabola + const trunk = new THREE.CylinderGeometry(0.14, 0.26, height, 5, 6, true); + trunk.translate(0, height / 2, 0); + { + const p = trunk.getAttribute('position'); + for (let i = 0; i < p.count; i++) { + const t = p.getY(i) / height; + p.setX(i, p.getX(i) + bx * bend * t * t); + p.setZ(i, p.getZ(i) + bz * bend * t * t); + } + } + parts.push(colorize(trunk, TRUNK_D, TRUNK)); + + const topX = bx * bend; + const topZ = bz * bend; + + // fronds: drooping strips arranged radially around the crown + const nFronds = 7 + Math.floor(rand() * 3); + for (let f = 0; f < nFronds; f++) { + const frond = new THREE.PlaneGeometry(0.55, 3.0 + rand() * 1.2, 1, 4); + frond.rotateX(-Math.PI / 2); // lie flat, length along -z .. +z + const p = frond.getAttribute('position'); + const len = 3.0; + for (let i = 0; i < p.count; i++) { + const t = Math.max(0, p.getZ(i) + len / 2) / len; // 0 at base, 1 at tip + p.setY(i, p.getY(i) - t * t * 2.0); // droop + p.setX(i, p.getX(i) * (1 - t * 0.75)); // taper + } + colorize(frond, FROND_A, FROND_B, 'y'); + const a = (f / nFronds) * Math.PI * 2 + rand() * 0.5; + frond.translate(0, 0, 0.5); + frond.rotateZ((rand() - 0.5) * 0.25); + frond.rotateY(a); + frond.translate(topX, height + 0.25, topZ); + parts.push(frond); + } + + // coconuts + for (let k = 0; k < 3; k++) { + const nut = new THREE.SphereGeometry(0.16, 5, 4); + const a = rand() * Math.PI * 2; + nut.translate(topX + Math.cos(a) * 0.3, height - 0.1, topZ + Math.sin(a) * 0.3); + parts.push(colorize(nut, COCONUT)); + } + + return mergeGeometries(parts); +} + +/** Undergrowth: a star of leafy planes. */ +function makeBushGeometry(rand, scale, dark) { + const parts = []; + const blades = 6; + for (let i = 0; i < blades; i++) { + const w = (0.9 + rand() * 0.5) * scale; + const h = (1.0 + rand() * 0.6) * scale; + const leaf = new THREE.PlaneGeometry(w, h, 1, 2); + const p = leaf.getAttribute('position'); + for (let v = 0; v < p.count; v++) { + const t = (p.getY(v) / h + 0.5); + p.setZ(v, t * t * 0.45 * scale); // curl outward + p.setX(v, p.getX(v) * (1 - t * 0.55)); + } + colorize(leaf, dark ? LEAF_DARK : FROND_A, FROND_B, 'y'); + leaf.translate(0, h * 0.42, 0.12 * scale); + leaf.rotateX(-0.5 - rand() * 0.35); + leaf.rotateY((i / blades) * Math.PI * 2 + rand()); + parts.push(leaf); + } + return mergeGeometries(parts); +} + +function makeRockGeometry(rand) { + const rock = new THREE.IcosahedronGeometry(1, 1); + const p = rock.getAttribute('position'); + for (let i = 0; i < p.count; i++) { + const s = 0.75 + rand() * 0.5; + p.setXYZ(i, p.getX(i) * s, p.getY(i) * s * 0.7, p.getZ(i) * s); + } + rock.computeVertexNormals(); + return colorize(rock, ROCK_C, new THREE.Color(0x9d968a)); +} + +export function buildVegetation(scene, timeUniform) { + const rand = mulberry32(4242); + const grad = { x: 0, z: 0 }; + + // --- gather placement points per kind + const palmPts = []; + const bushPts = []; + const rockPts = []; + for (const isl of ISLANDS) { + const nTry = Math.round((isl.r * isl.r) / 55); + for (let i = 0; i < nTry; i++) { + const a = rand() * Math.PI * 2; + const r = Math.sqrt(rand()) * isl.r * 1.02; + const x = isl.cx + Math.cos(a) * r; + const z = isl.cz + Math.sin(a) * r; + const y = terrainHeightAt(x, z); + terrainGradientAt(x, z, grad); + const slope = Math.hypot(grad.x, grad.z); + const roll = rand(); + if (y > 1.9 && y < isl.height * 0.8 && slope < 0.55 && roll < 0.42) { + palmPts.push({ x, y, z }); + } else if (y > 1.6 && slope < 0.7 && roll < 0.78) { + bushPts.push({ x, y, z }); + } else if (y > 0.35 && y < 5 && roll < 0.92) { + rockPts.push({ x, y, z }); + } + } + } + + const sets = []; + + // --- palms: 3 variants, sway in the wind via a shader patch + const palmVariants = [ + makePalmGeometry(mulberry32(11), 7.5), + makePalmGeometry(mulberry32(22), 9.5), + makePalmGeometry(mulberry32(33), 11.0), + ]; + const palmMat = new THREE.MeshStandardMaterial({ + vertexColors: true, + flatShading: true, + roughness: 1, + side: THREE.DoubleSide, + alphaTest: 0, + }); + palmMat.onBeforeCompile = (shader) => { + shader.uniforms.uTime = timeUniform; + shader.vertexShader = + 'uniform float uTime;\n' + + shader.vertexShader.replace( + '#include ', + `#include + { + vec3 instPos = vec3(instanceMatrix[3][0], instanceMatrix[3][1], instanceMatrix[3][2]); + float phase = instPos.x * 0.31 + instPos.z * 0.27; + float swayAmt = pow(max(transformed.y, 0.0) / 10.0, 2.0); + transformed.x += sin(uTime * 1.15 + phase) * swayAmt * 0.55; + transformed.z += cos(uTime * 0.93 + phase * 1.3) * swayAmt * 0.45; + }` + ); + }; + palmVariants.forEach((geo, vi) => { + const pts = palmPts.filter((_, i) => i % palmVariants.length === vi); + sets.push(makeInstances(geo, palmMat, pts, rand, { minS: 0.8, maxS: 1.35, tilt: 0.1, sink: 0.25 })); + }); + + // --- undergrowth + const bushGeoA = makeBushGeometry(mulberry32(44), 1.6, false); + const bushGeoB = makeBushGeometry(mulberry32(55), 2.6, true); + const bushMat = new THREE.MeshStandardMaterial({ + vertexColors: true, + flatShading: true, + roughness: 1, + side: THREE.DoubleSide, + }); + const bushA = bushPts.filter((_, i) => i % 2 === 0); + const bushB = bushPts.filter((_, i) => i % 2 === 1); + sets.push(makeInstances(bushGeoA, bushMat, bushA, rand, { minS: 0.7, maxS: 1.5, tilt: 0.12, sink: 0.1 })); + sets.push(makeInstances(bushGeoB, bushMat, bushB, rand, { minS: 0.7, maxS: 1.4, tilt: 0.12, sink: 0.1 })); + + // --- rocks + const rockMat = new THREE.MeshStandardMaterial({ vertexColors: true, flatShading: true, roughness: 1 }); + sets.push(makeInstances(makeRockGeometry(mulberry32(66)), rockMat, rockPts, rand, { minS: 0.5, maxS: 2.4, tilt: 0.4, sink: 0.4 })); + + let count = 0; + for (const s of sets) { + if (!s) continue; + scene.add(s); + count += s.count; + } + return { count }; +} + +function makeInstances(geo, mat, pts, rand, opt) { + if (pts.length === 0) return null; + const mesh = new THREE.InstancedMesh(geo, mat, pts.length); + const m = new THREE.Matrix4(); + const q = new THREE.Quaternion(); + const e = new THREE.Euler(); + const s = new THREE.Vector3(); + const p = new THREE.Vector3(); + pts.forEach((pt, i) => { + const scale = opt.minS + rand() * (opt.maxS - opt.minS); + e.set((rand() - 0.5) * opt.tilt * 2, rand() * Math.PI * 2, (rand() - 0.5) * opt.tilt * 2); + q.setFromEuler(e); + s.setScalar(scale); + p.set(pt.x, pt.y - opt.sink, pt.z); + m.compose(p, q, s); + mesh.setMatrixAt(i, m); + }); + mesh.instanceMatrix.needsUpdate = true; + mesh.castShadow = true; + mesh.receiveShadow = true; + return mesh; +} diff --git a/pirate-ship/src/waves.js b/pirate-ship/src/waves.js index d5f77a8..787e04f 100644 --- a/pirate-ship/src/waves.js +++ b/pirate-ship/src/waves.js @@ -43,9 +43,9 @@ export const GEO_WAVES = [ makeWave(61, 64, 0.62, 0.65), // secondary swell makeWave(27, 38, 0.34, 0.7), makeWave(74, 23, 0.21, 0.72), - makeWave(12, 15, 0.135, 0.78), - makeWave(56, 9.5, 0.072, 0.8), - makeWave(33, 6.3, 0.038, 0.85), + makeWave(8, 15, 0.13, 0.78), + makeWave(66, 9.5, 0.062, 0.8), + makeWave(30, 6.3, 0.03, 0.85), ]; export const DETAIL_WAVES = [ @@ -163,11 +163,16 @@ export function packWaves(waves) { return { a, b, count: waves.length }; } -/** GLSL snippet implementing the same Gerstner sum (geometry waves). */ +/** + * GLSL snippet implementing the same Gerstner sum (geometry waves). + * Each wave fades out with camera distance proportionally to its wavelength, + * so short chop never aliases on the coarse distant mesh; the physics + * (sampled near the ship, well inside every fade range) stays exact. + */ export const GERSTNER_GLSL = /* glsl */ ` struct WaveOut { vec3 disp; vec3 normal; float crest; }; -WaveOut gerstner(vec2 p0, float t, vec4 waveA[NUM_GEO_WAVES], vec4 waveB[NUM_GEO_WAVES], float fade) { +WaveOut gerstner(vec2 p0, float t, vec4 waveA[NUM_GEO_WAVES], vec4 waveB[NUM_GEO_WAVES], float dist) { vec3 disp = vec3(0.0); vec3 n = vec3(0.0, 1.0, 0.0); float crest = 0.0; @@ -175,6 +180,8 @@ WaveOut gerstner(vec2 p0, float t, vec4 waveA[NUM_GEO_WAVES], vec4 waveB[NUM_GEO vec2 D = waveA[i].xy; float k = waveA[i].z; float om = waveA[i].w; + float L = 6.2831853 / k; + float fade = 1.0 - smoothstep(L * 30.0, L * 95.0, dist); float amp = waveB[i].x * fade; float q = waveB[i].y; float f = k * dot(D, p0) - om * t + waveB[i].z; From 4b7c1360e6cdc7e5461e667b096f3dfac894a3d7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 10 Jun 2026 16:50:16 +0000 Subject: [PATCH 3/6] pirate-ship: tune grounding/foam, perf-safe physics catch-up, README, deploy to Pages Co-authored-by: ilikevibecoding --- .github/workflows/deploy-pages.yml | 2 +- pirate-ship/README.md | 70 ++++++++++++++++++++++++++++++ pirate-ship/src/main.js | 7 ++- pirate-ship/src/ocean.js | 12 ++--- pirate-ship/src/physics.js | 17 +++++--- pirate-ship/src/vegetation.js | 4 +- 6 files changed, 95 insertions(+), 17 deletions(-) create mode 100644 pirate-ship/README.md diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index a788959..ed15ddb 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -29,7 +29,7 @@ jobs: - name: Prepare site run: | mkdir -p dist - cp -R index.html browser-phone dist/ + cp -R index.html browser-phone pirate-ship dist/ cp CNAME dist/CNAME - name: Setup Pages diff --git a/pirate-ship/README.md b/pirate-ship/README.md new file mode 100644 index 0000000..d4bf23f --- /dev/null +++ b/pirate-ship/README.md @@ -0,0 +1,70 @@ +# ☠ Pirate Cove — Sail the Jungle Isles + +A browser pirate-ship sailing game built with Three.js. No build step, no +assets to download — everything (ship, islands, jungle, ocean) is generated +procedurally in code. Open `index.html` from any static server and sail. + +## Controls + +| Input | Action | +| --- | --- | +| `W` / `S` | more / less sail (Anchored → Slow → Half → Full) | +| `A` / `D` | rudder (turns scale with boat speed) | +| `Space` | drop / weigh anchor | +| `R` | reset ship to spawn | +| `H` | toggle the help card | +| mouse drag | orbit the camera | +| scroll | zoom | + +Sail with the wind (see the dial, top right) for extra speed. + +## How the water physics works + +- **One wave model, two consumers.** `src/waves.js` defines a sum of Gerstner + (trochoidal) waves — swells, mid waves and chop. The exact same wave list is + packed into shader uniforms (GPU displaces the ocean mesh vertices and + computes analytic normals) and evaluated in JS for the physics. The ship + floats on precisely the surface you see. +- **True height sampling.** Gerstner waves displace water horizontally as well + as vertically, so "height at (x, z)" needs the inverse of the horizontal + displacement — solved with a fast fixed-point iteration (≈ millimetre + accuracy, verified by tests). +- **Buoyancy probes.** The hull carries 14 probes spread over its footprint. + Each samples the live wave field and contributes a depth-proportional + buoyancy force at its location — the ship naturally heaves, pitches, rolls, + and rides swells. Per-probe damping acts on velocity *relative to the moving + water surface*, which kills jitter without making the sea feel sticky. +- **Sailing model.** Sail thrust (modulated by wind alignment), speed-dependent + rudder yaw, heel in turns and from beam wind, strong lateral keel drag, + quadratic hull drag, plus soft grounding against the islands' terrain field. +- **Fixed timestep.** Physics runs at 60 Hz with an accumulator, independent of + render rate. + +## The environment + +- The ocean is a single radial mesh centred on the ship — dense near the + camera, geometrically coarser toward a ~7 km horizon. Small waves fade with + distance in the shader (no aliasing), fog hides the rim. +- The water shader colours by true depth against the islands' terrain field: + turquoise shallows, navy deeps, crest foam from wave steepness, animated + shore foam, sun glints and a fresnel sky reflection. +- Islands are radial-harmonic mounds shared by three consumers: terrain mesh, + physics collision, and the ocean shader's depth tint — all from + `src/islandField.js`. +- Jungle: instanced palms (wind-swayed in the vertex shader), undergrowth, + rocks, drifting clouds, circling gulls, wake ribbon, bow spray and a + wave-conforming contact shadow under the hull. + +## Performance + +Designed to stay light: ~27 draw calls and ~430 k triangles in view, one +2048 px shadow map, pixel ratio capped at 1.6, instancing for all vegetation. + +## Tests + +``` +node pirate-ship/test/physics.test.mjs +``` + +Covers wave-inversion accuracy, flotation stability, drive/steering behaviour +and island grounding. diff --git a/pirate-ship/src/main.js b/pirate-ship/src/main.js index 1cc0afe..c54bbb2 100644 --- a/pirate-ship/src/main.js +++ b/pirate-ship/src/main.js @@ -73,11 +73,16 @@ function frame() { controls.applyInput(); accumulator += dt; - while (accumulator >= FIXED_DT) { + // catch up physics, but never more than 5 steps per frame: on very slow + // machines we'd rather run slightly slow-motion than starve the renderer + let steps = 0; + while (accumulator >= FIXED_DT && steps < 5) { simTime += FIXED_DT; body.step(FIXED_DT, simTime); accumulator -= FIXED_DT; + steps++; } + if (accumulator > FIXED_DT) accumulator = FIXED_DT; timeUniform.value = simTime; ship.update(body, simTime, dt); diff --git a/pirate-ship/src/ocean.js b/pirate-ship/src/ocean.js index a13a253..c50746a 100644 --- a/pirate-ship/src/ocean.js +++ b/pirate-ship/src/ocean.js @@ -133,7 +133,7 @@ void main() { // --- water body colour from depth (terrain field mirrors the islands) float terrain = terrainHeight(vWorld.xz, uIslA, uIslB, uIslC); float depth = max(vWorld.y - terrain, 0.0); - float shallow = exp(-depth * 0.14); + float shallow = exp(-depth * 0.18); vec3 base = mix(uDeepColor, uShallowColor, clamp(shallow, 0.0, 1.0)); // subtle subsurface glow on sun-facing wave flanks @@ -155,11 +155,11 @@ void main() { breakup = 0.65 + 0.35 * breakup; float crestFoam = smoothstep(0.3, 0.72, vCrest / 0.28) * breakup; - float wob = sin(uTime * 1.25 + vWorld.x * 0.11 + vWorld.z * 0.085) * 0.55; - float shoreFoam = (1.0 - smoothstep(0.0, 2.3 + wob, depth - 0.25)) - * (0.6 + 0.4 * sin(depth * 3.1 - uTime * 2.2)); - shoreFoam += smoothstep(0.75, 1.0, sin(depth * 1.9 - uTime * 1.45)) * - (1.0 - smoothstep(0.0, 6.5, depth)) * 0.5; + float wob = sin(uTime * 1.25 + vWorld.x * 0.11 + vWorld.z * 0.085) * 0.35; + float shoreFoam = (1.0 - smoothstep(0.0, 1.4 + wob, depth - 0.2)) + * (0.55 + 0.45 * sin(depth * 3.1 - uTime * 2.2)); + shoreFoam += smoothstep(0.78, 1.0, sin(depth * 1.9 - uTime * 1.45)) * + (1.0 - smoothstep(0.0, 4.5, depth)) * 0.38; shoreFoam = clamp(shoreFoam, 0.0, 1.0) * step(0.001, depth); float foam = clamp(crestFoam * 0.75 + shoreFoam * 0.95, 0.0, 1.0) * vFade; diff --git a/pirate-ship/src/physics.js b/pirate-ship/src/physics.js index 556d706..c880700 100644 --- a/pirate-ship/src/physics.js +++ b/pirate-ship/src/physics.js @@ -53,8 +53,8 @@ export const TUNE = { heelWind: 0.5, // heel from beam wind on sails keelRighting: 16, // artificial righting torque (anti-capsize) anchorDrag: 1.4, - groundSpring: 26, - groundFriction: 2.2, + groundSpring: 15, + groundFriction: 4.5, }; export const SAIL_SETTINGS = [ @@ -280,16 +280,19 @@ export class ShipPhysics { const pen = ground - keelY; if (pen <= 0) continue; this.aground = true; - const capped = Math.min(pen, 1.4); + const capped = Math.min(pen, 1.0); const g = terrainGradientAt(px, pz, this._g); // uphill const gl = Math.hypot(g.x, g.z) || 1; const dhx = -g.x / gl; // downhill (push back to sea) const dhz = -g.z / gl; - // spring push + lift, applied at the contact -> the bow swings off shore + // gentle spring push + lift, applied at the contact, and only while the + // ship still moves shoreward or sits still — never slingshots it out + const vDown = this.vel.x * dhx + this.vel.z * dhz; // speed already heading to sea + const springScale = vDown > 1.5 ? 0.15 : 1.0; this._applyAt( - dhx * capped * T.groundSpring, - capped * T.groundSpring * 0.35, - dhz * capped * T.groundSpring, + dhx * capped * T.groundSpring * springScale, + capped * T.groundSpring * 0.25, + dhz * capped * T.groundSpring * springScale, r.x, r.y, r.z, diff --git a/pirate-ship/src/vegetation.js b/pirate-ship/src/vegetation.js index 2ef7f26..ab6037c 100644 --- a/pirate-ship/src/vegetation.js +++ b/pirate-ship/src/vegetation.js @@ -196,8 +196,8 @@ export function buildVegetation(scene, timeUniform) { }); // --- undergrowth - const bushGeoA = makeBushGeometry(mulberry32(44), 1.6, false); - const bushGeoB = makeBushGeometry(mulberry32(55), 2.6, true); + const bushGeoA = makeBushGeometry(mulberry32(44), 1.5, false); + const bushGeoB = makeBushGeometry(mulberry32(55), 1.9, true); const bushMat = new THREE.MeshStandardMaterial({ vertexColors: true, flatShading: true, From 4e7cbcfba2af4907d0fc05e0e77a3769767adcc3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 10 Jun 2026 18:06:56 +0000 Subject: [PATCH 4/6] =?UTF-8?q?pirate-ship:=20fix=20hull=20holes=20?= =?UTF-8?q?=E2=80=94=20cap=20bow,=20DoubleSide=20shell,=20seal=20deck=20sl?= =?UTF-8?q?ot,=20seat=20cannons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: ilikevibecoding --- pirate-ship/src/ship.js | 54 +++++++++++++++++++++++++++++++++++------ 1 file changed, 47 insertions(+), 7 deletions(-) diff --git a/pirate-ship/src/ship.js b/pirate-ship/src/ship.js index cafd6ca..5278bd8 100644 --- a/pirate-ship/src/ship.js +++ b/pirate-ship/src/ship.js @@ -64,6 +64,14 @@ function buildHullGeometry() { for (let i = 0; i < ringSize - 1; i++) { idx.push(i, i + 1, sternCenter); } + // bow cap (otherwise the bow ring is a gaping hole) + const bowCenter = verts.length / 3; + const stN = STATIONS[STATIONS.length - 1]; + const bowBase = (STATIONS.length - 1) * ringSize; + verts.push(0, (stN.keel + stN.deck) / 2, stN.z); + for (let i = 0; i < ringSize - 1; i++) { + idx.push(bowBase + i + 1, bowBase + i, bowCenter); + } const geo = new THREE.BufferGeometry(); geo.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3)); geo.setIndex(idx); @@ -72,12 +80,31 @@ function buildHullGeometry() { return non; } +function stationAt(z) { + // interpolate hull half-width / deck height along the ship + let a = STATIONS[0]; + let b = STATIONS[STATIONS.length - 1]; + for (let i = 0; i < STATIONS.length - 1; i++) { + if (z >= STATIONS[i].z && z <= STATIONS[i + 1].z) { + a = STATIONS[i]; + b = STATIONS[i + 1]; + break; + } + } + const t = (z - a.z) / (b.z - a.z || 1); + return { + w: a.w + (b.w - a.w) * t, + deck: a.deck + (b.deck - a.deck) * t, + keel: a.keel + (b.keel - a.keel) * t, + }; +} + function buildDeckGeometry() { - // deck surface with slight camber, inset from the bulwark + // deck surface with slight camber, overlapping the hull sides (no slot) const verts = []; const idx = []; STATIONS.forEach((st) => { - const w = st.w * 0.93; + const w = st.w * 0.99; verts.push(-w, st.deck, st.z, 0, st.deck + 0.18, st.z, w, st.deck, st.z); }); for (let s = 0; s < STATIONS.length - 1; s++) { @@ -199,8 +226,20 @@ export class PirateShip { constructor(scene) { this.group = new THREE.Group(); - const matHull = new THREE.MeshStandardMaterial({ color: 0x53381f, roughness: 0.85, flatShading: true }); - const matWood = new THREE.MeshStandardMaterial({ color: 0x96704a, roughness: 0.9, flatShading: true }); + // DoubleSide: the hull is a shell — without it, the inner faces are + // culled and the ship looks like it has holes when seen over the rail. + const matHull = new THREE.MeshStandardMaterial({ + color: 0x53381f, + roughness: 0.85, + flatShading: true, + side: THREE.DoubleSide, + }); + const matWood = new THREE.MeshStandardMaterial({ + color: 0x96704a, + roughness: 0.9, + flatShading: true, + side: THREE.DoubleSide, // deck is a sheet; masts/spars unaffected + }); const matDark = new THREE.MeshStandardMaterial({ color: 0x3a2a19, roughness: 0.9, flatShading: true }); const matGold = new THREE.MeshStandardMaterial({ color: 0xd8a93f, roughness: 0.45, metalness: 0.55 }); this.matSail = new THREE.MeshStandardMaterial({ @@ -229,12 +268,13 @@ export class PirateShip { goldParts.push(new THREE.BoxGeometry(4.4, 0.28, 0.18).translate(0, 3.45, -13.95)); // stern trim goldParts.push(new THREE.BoxGeometry(3.6, 0.2, 0.16).translate(0, 2.6, -14.0)); - // cannons poking through the bulwarks + // cannons poking through the bulwarks, hugging the hull at each station for (const side of [-1, 1]) { for (const z of [-6.5, -2.5, 1.5, 5.5]) { - const g = new THREE.CylinderGeometry(0.13, 0.16, 1.5, 6) + const st = stationAt(z); + const g = new THREE.CylinderGeometry(0.13, 0.16, 1.6, 6) .rotateZ(Math.PI / 2) - .translate(side * 3.55, 1.45, z); + .translate(side * (st.w - 0.2), st.deck - 0.55, z); darkParts.push(g); } } From 483cc446f5647b8b3da197ac8f4e62491db451cf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 10 Jun 2026 18:18:54 +0000 Subject: [PATCH 5/6] =?UTF-8?q?pirate-ship:=20realistic=20islands=20?= =?UTF-8?q?=E2=80=94=20ridged=20terrain,=20biome=20coloring,=20canopy=20tr?= =?UTF-8?q?ees,=20denser=20jungle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: ilikevibecoding --- pirate-ship/src/islands.js | 87 ++++++++++++++++++++++++++--------- pirate-ship/src/vegetation.js | 75 ++++++++++++++++++++++++++++-- 2 files changed, 136 insertions(+), 26 deletions(-) diff --git a/pirate-ship/src/islands.js b/pirate-ship/src/islands.js index e7efb4a..31074b1 100644 --- a/pirate-ship/src/islands.js +++ b/pirate-ship/src/islands.js @@ -8,22 +8,35 @@ import * as THREE from 'three'; import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js'; import { ISLANDS, islandHeightAt, terrainHeightAt } from './islandField.js'; import { createFbm2D } from './noise.js'; +import { mulberry32 } from './noise.js'; -const C_SAND = new THREE.Color(0xe6d49c); -const C_SAND_WET = new THREE.Color(0xc5ad7c); -const C_GRASS_A = new THREE.Color(0x2e7136); -const C_GRASS_B = new THREE.Color(0x55a047); +const C_SAND = new THREE.Color(0xe2cf96); +const C_SAND_WET = new THREE.Color(0xb89f70); +const C_JUNGLE_DEEP = new THREE.Color(0x1f5a2d); +const C_JUNGLE = new THREE.Color(0x2f7a38); +const C_GRASS = new THREE.Color(0x6a9c44); +const C_GRASS_DRY = new THREE.Color(0x96a04f); +const C_DIRT = new THREE.Color(0x7a5f3e); const C_ROCK = new THREE.Color(0x8a8273); -const C_ROCK_DARK = new THREE.Color(0x6e6759); +const C_ROCK_DARK = new THREE.Color(0x5f594d); +const C_ROCK_HIGH = new THREE.Color(0x9b9488); export function buildIslands(scene) { const fbm = createFbm2D(9001, 4); + const ridgeFbm = createFbm2D(7007, 3); const colorFbm = createFbm2D(5005, 3); + const patchFbm = createFbm2D(3303, 2); const parts = []; for (const isl of ISLANDS) { - const segs = Math.round(THREE.MathUtils.clamp(isl.r * 0.55, 40, 92)); - const rings = Math.round(THREE.MathUtils.clamp(isl.r * 0.26, 18, 48)); + // per-island character: some are craggy, some gentle + const rnd = mulberry32(isl.seed * 7 + 3); + const bumpAmt = 3.2 + rnd() * 3.4; + const ridgeAmt = (0.35 + rnd() * 0.65) * Math.min(isl.height * 0.16, 9); + const tintShift = (rnd() - 0.5) * 0.10; // hue-ish variation between islands + + const segs = Math.round(THREE.MathUtils.clamp(isl.r * 0.8, 56, 120)); + const rings = Math.round(THREE.MathUtils.clamp(isl.r * 0.36, 24, 62)); const maxR = isl.r * 1.3; // extend below sea level so the shoreline is sealed const verts = []; const cols = []; @@ -37,12 +50,17 @@ export function buildIslands(scene) { const x = isl.cx + Math.cos(a) * rad; const z = isl.cz + Math.sin(a) * rad; let y = islandHeightAt(isl, x, z); - // interior detail bumps — only well above the shoreline so the - // physics/shader field stays exact where it matters (the beach) + // interior detail — only well above the shoreline so the physics & + // shader field stays exact where it matters (the beach/collision) if (y > 2.0) { const inland = Math.min((y - 2.0) / 6.0, 1); - y += fbm(x * 0.02, z * 0.02) * 4.2 * inland; - y += fbm(x * 0.07, z * 0.07) * 1.3 * inland; + const elev = Math.min(y / isl.height, 1); + // rolling bumps + y += fbm(x * 0.02, z * 0.02) * bumpAmt * inland; + y += fbm(x * 0.07, z * 0.07) * 1.4 * inland; + // ridged crags toward the summit (sharp |noise| creases) + const ridge = 1 - Math.abs(ridgeFbm(x * 0.016, z * 0.016)); + y += ridge * ridge * ridgeAmt * elev * elev * inland; } verts.push(x, y, z); cols.push(0, 0, 0); // filled after normals are known @@ -69,30 +87,55 @@ export function buildIslands(scene) { geo.setIndex(idx); geo.computeVertexNormals(); - // colour by height + slope + // colour by height, slope, and noise-driven biome patches const pos = geo.getAttribute('position'); const nor = geo.getAttribute('normal'); const col = new Float32Array(pos.count * 3); const c = new THREE.Color(); + const tone = new THREE.Color(); + const peak = isl.height * 1.15 + ridgeAmt; for (let v = 0; v < pos.count; v++) { const x = pos.getX(v); const y = pos.getY(v); const z = pos.getZ(v); const slope = 1 - nor.getY(v); // 0 flat .. 1 vertical + const elev = Math.max(y, 0) / peak; const n = colorFbm(x * 0.045, z * 0.045) * 0.5 + 0.5; + const patch = patchFbm(x * 0.02 + 31, z * 0.02 - 17) * 0.5 + 0.5; + const grain = colorFbm(x * 0.16, z * 0.16) * 0.5 + 0.5; - if (y < 0.45) c.copy(C_SAND_WET); - else if (y < 1.7) c.copy(C_SAND).lerp(C_SAND_WET, Math.max(0, (1.0 - y) * 0.4)); - else { - c.copy(C_GRASS_A).lerp(C_GRASS_B, n); - // blend sand->grass across the 1.7..2.6 band - if (y < 2.6) c.lerp(C_SAND, (2.6 - y) / 0.9); + // wobble the vegetation line with noise so it isn't a straight contour + const sandLine = 1.7 + (patch - 0.5) * 1.5; + if (y < 0.5) { + // wet sand at the waterline + c.copy(C_SAND_WET).lerp(C_SAND, grain * 0.25); + } else if (y < sandLine) { + // dry beach with subtle grain so the band isn't flat + c.copy(C_SAND).lerp(C_SAND_WET, (1 - y * 0.45) * 0.25 + grain * 0.12); + } else { + // vegetation: deep jungle in the lows, open glades and dry grass + // patches higher up, occasional dirt breaks + c.copy(C_JUNGLE).lerp(C_JUNGLE_DEEP, n * 0.8); + if (patch > 0.62) c.lerp(C_GRASS, (patch - 0.62) / 0.38 * 0.85); + if (patch < 0.22) c.lerp(C_DIRT, (0.22 - patch) / 0.22 * 0.5); + // higher slopes dry out + c.lerp(C_GRASS_DRY, THREE.MathUtils.clamp((elev - 0.45) * 1.6, 0, 0.55) * patch); + // per-island tint variation + if (tintShift > 0) c.lerp(C_GRASS_DRY, tintShift); + else c.lerp(C_JUNGLE_DEEP, -tintShift); + // blend sand->jungle across a noisy transition band + if (y < sandLine + 1.1) c.lerp(C_SAND, (sandLine + 1.1 - y) / 1.1 * 0.85); } - if (slope > 0.38 && y > 1.2) { - c.copy(C_ROCK).lerp(C_ROCK_DARK, n); - } else if (slope > 0.3 && y > 1.2) { - c.lerp(C_ROCK, (slope - 0.3) / 0.08 * 0.7); + + // exposed rock: steep slopes and craggy summits + const rockiness = THREE.MathUtils.clamp((slope - 0.32) / 0.2, 0, 1) * (y > 1.2 ? 1 : 0); + const summitRock = THREE.MathUtils.clamp((elev - 0.72) * 4.0, 0, 1) * (slope > 0.18 ? 1 : 0.4); + const rk = Math.max(rockiness, summitRock); + if (rk > 0) { + tone.copy(elev > 0.6 ? C_ROCK_HIGH : C_ROCK).lerp(C_ROCK_DARK, n * 0.7); + c.lerp(tone, Math.min(rk, 1)); } + col[v * 3] = c.r; col[v * 3 + 1] = c.g; col[v * 3 + 2] = c.b; diff --git a/pirate-ship/src/vegetation.js b/pirate-ship/src/vegetation.js index ab6037c..bdbbf19 100644 --- a/pirate-ship/src/vegetation.js +++ b/pirate-ship/src/vegetation.js @@ -119,6 +119,50 @@ function makeBushGeometry(rand, scale, dark) { return mergeGeometries(parts); } +/** Broadleaf jungle canopy tree: trunk + clustered leaf blobs. */ +function makeCanopyTreeGeometry(rand, height) { + const parts = []; + const lean = (rand() - 0.5) * 0.8; + const leanDir = rand() * Math.PI * 2; + const lx = Math.cos(leanDir) * lean; + const lz = Math.sin(leanDir) * lean; + + const trunk = new THREE.CylinderGeometry(0.22, 0.38, height, 6, 3); + trunk.translate(0, height / 2, 0); + { + const p = trunk.getAttribute('position'); + for (let i = 0; i < p.count; i++) { + const t = p.getY(i) / height; + p.setX(i, p.getX(i) + lx * t * t * 2.2); + p.setZ(i, p.getZ(i) + lz * t * t * 2.2); + } + } + // IcosahedronGeometry blobs are non-indexed; merge requires consistency + parts.push(colorize(trunk.toNonIndexed(), TRUNK_D, TRUNK)); + + // canopy: 3-4 squashed, jittered leaf blobs + const blobs = 3 + Math.floor(rand() * 2); + for (let b = 0; b < blobs; b++) { + const r = 1.7 + rand() * 1.4; + const blob = new THREE.IcosahedronGeometry(r, 1); + const p = blob.getAttribute('position'); + for (let i = 0; i < p.count; i++) { + const s = 0.82 + rand() * 0.36; + p.setXYZ(i, p.getX(i) * s, p.getY(i) * s * 0.62, p.getZ(i) * s); + } + blob.computeVertexNormals(); + const a = rand() * Math.PI * 2; + const d = b === 0 ? 0 : 0.9 + rand() * 1.6; + blob.translate( + lx * 2.2 + Math.cos(a) * d, + height - 0.4 + (rand() - 0.3) * 1.4, + lz * 2.2 + Math.sin(a) * d + ); + parts.push(colorize(blob, LEAF_DARK, FROND_B)); + } + return mergeGeometries(parts); +} + function makeRockGeometry(rand) { const rock = new THREE.IcosahedronGeometry(1, 1); const p = rock.getAttribute('position'); @@ -136,10 +180,11 @@ export function buildVegetation(scene, timeUniform) { // --- gather placement points per kind const palmPts = []; + const canopyPts = []; const bushPts = []; const rockPts = []; for (const isl of ISLANDS) { - const nTry = Math.round((isl.r * isl.r) / 55); + const nTry = Math.round((isl.r * isl.r) / 42); for (let i = 0; i < nTry; i++) { const a = rand() * Math.PI * 2; const r = Math.sqrt(rand()) * isl.r * 1.02; @@ -149,11 +194,15 @@ export function buildVegetation(scene, timeUniform) { terrainGradientAt(x, z, grad); const slope = Math.hypot(grad.x, grad.z); const roll = rand(); - if (y > 1.9 && y < isl.height * 0.8 && slope < 0.55 && roll < 0.42) { + // palms own the coast band; broadleaf canopy fills the interior; + // undergrowth everywhere; rocks near the beaches and clearings + if (y > 1.9 && y < 9 && slope < 0.55 && roll < 0.5) { palmPts.push({ x, y, z }); - } else if (y > 1.6 && slope < 0.7 && roll < 0.78) { + } else if (y > 4.5 && y < isl.height * 0.88 && slope < 0.62 && roll < 0.42) { + canopyPts.push({ x, y, z }); + } else if (y > 1.6 && slope < 0.75 && roll < 0.55) { bushPts.push({ x, y, z }); - } else if (y > 0.35 && y < 5 && roll < 0.92) { + } else if (y > 0.35 && y < 6 && roll < 0.72) { rockPts.push({ x, y, z }); } } @@ -195,6 +244,18 @@ export function buildVegetation(scene, timeUniform) { sets.push(makeInstances(geo, palmMat, pts, rand, { minS: 0.8, maxS: 1.35, tilt: 0.1, sink: 0.25 })); }); + // --- jungle canopy trees (2 variants) + const canopyMat = new THREE.MeshStandardMaterial({ + vertexColors: true, + flatShading: true, + roughness: 1, + }); + const canopyVariants = [makeCanopyTreeGeometry(mulberry32(77), 6.5), makeCanopyTreeGeometry(mulberry32(88), 8.5)]; + canopyVariants.forEach((geo, vi) => { + const pts = canopyPts.filter((_, i) => i % canopyVariants.length === vi); + sets.push(makeInstances(geo, canopyMat, pts, rand, { minS: 0.75, maxS: 1.5, tilt: 0.08, sink: 0.3 })); + }); + // --- undergrowth const bushGeoA = makeBushGeometry(mulberry32(44), 1.5, false); const bushGeoB = makeBushGeometry(mulberry32(55), 1.9, true); @@ -230,6 +291,7 @@ function makeInstances(geo, mat, pts, rand, opt) { const e = new THREE.Euler(); const s = new THREE.Vector3(); const p = new THREE.Vector3(); + const c = new THREE.Color(); pts.forEach((pt, i) => { const scale = opt.minS + rand() * (opt.maxS - opt.minS); e.set((rand() - 0.5) * opt.tilt * 2, rand() * Math.PI * 2, (rand() - 0.5) * opt.tilt * 2); @@ -238,8 +300,13 @@ function makeInstances(geo, mat, pts, rand, opt) { p.set(pt.x, pt.y - opt.sink, pt.z); m.compose(p, q, s); mesh.setMatrixAt(i, m); + // subtle per-instance tint so foliage doesn't look copy-pasted + const b = 0.82 + rand() * 0.3; + c.setRGB(b * (0.94 + rand() * 0.12), b, b * (0.92 + rand() * 0.1)); + mesh.setColorAt(i, c); }); mesh.instanceMatrix.needsUpdate = true; + if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true; mesh.castShadow = true; mesh.receiveShadow = true; return mesh; From 256250fd7d64b2edfa7a155b18f1a68d86e4e893 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 10 Jun 2026 18:59:00 +0000 Subject: [PATCH 6/6] =?UTF-8?q?pirate-ship:=20jungle=20overhaul=20?= =?UTF-8?q?=E2=80=94=20smooth=20terrain=20shading,=20clustered=20canopy=20?= =?UTF-8?q?w/=20ground=20AO,=20leafy=20shrubs,=20budget=20trim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: ilikevibecoding --- pirate-ship/src/islandField.js | 12 +++++- pirate-ship/src/islands.js | 27 +++++++------ pirate-ship/src/vegetation.js | 73 +++++++++++++++++++++++----------- 3 files changed, 75 insertions(+), 37 deletions(-) diff --git a/pirate-ship/src/islandField.js b/pirate-ship/src/islandField.js index 40d4a4d..9508610 100644 --- a/pirate-ship/src/islandField.js +++ b/pirate-ship/src/islandField.js @@ -8,7 +8,7 @@ // * shallow-water tint + shore foam in the ocean shader (GLSL mirror below) // --------------------------------------------------------------------------- -import { mulberry32 } from './noise.js'; +import { mulberry32, createFbm2D } from './noise.js'; export const SEA_FLOOR_DEPTH = 16; // open-ocean seabed depth (m) export const BEACH_SLOPE = 0.085; // vertical rise per metre across the beach @@ -93,6 +93,16 @@ export function terrainHeightAt(x, z) { return y; } +// Jungle canopy density mask, 0 (clearing) .. 1 (dense canopy). +// Shared by vegetation placement AND terrain colouring, so trees grow in +// organic clusters and the ground visibly darkens beneath them. +const _jungleFbm = createFbm2D(7777, 3); +export function jungleDensityAt(x, z) { + const n = _jungleFbm(x * 0.016, z * 0.016) * 0.5 + 0.5; + const t = Math.min(Math.max((n - 0.36) / (0.72 - 0.36), 0), 1); + return t * t * (3 - 2 * t); // smoothstep +} + /** Finite-difference terrain gradient (uphill direction). */ export function terrainGradientAt(x, z, out) { const e = 2.0; diff --git a/pirate-ship/src/islands.js b/pirate-ship/src/islands.js index 31074b1..b99c306 100644 --- a/pirate-ship/src/islands.js +++ b/pirate-ship/src/islands.js @@ -6,17 +6,17 @@ import * as THREE from 'three'; import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js'; -import { ISLANDS, islandHeightAt, terrainHeightAt } from './islandField.js'; +import { ISLANDS, islandHeightAt, terrainHeightAt, jungleDensityAt } from './islandField.js'; import { createFbm2D } from './noise.js'; import { mulberry32 } from './noise.js'; const C_SAND = new THREE.Color(0xe2cf96); const C_SAND_WET = new THREE.Color(0xb89f70); -const C_JUNGLE_DEEP = new THREE.Color(0x1f5a2d); -const C_JUNGLE = new THREE.Color(0x2f7a38); -const C_GRASS = new THREE.Color(0x6a9c44); -const C_GRASS_DRY = new THREE.Color(0x96a04f); -const C_DIRT = new THREE.Color(0x7a5f3e); +const C_JUNGLE_DEEP = new THREE.Color(0x17421f); +const C_JUNGLE = new THREE.Color(0x2c6e33); +const C_GRASS = new THREE.Color(0x67953f); +const C_GRASS_DRY = new THREE.Color(0x8e9a4b); +const C_DIRT = new THREE.Color(0x6e553a); const C_ROCK = new THREE.Color(0x8a8273); const C_ROCK_DARK = new THREE.Color(0x5f594d); const C_ROCK_HIGH = new THREE.Color(0x9b9488); @@ -113,16 +113,16 @@ export function buildIslands(scene) { // dry beach with subtle grain so the band isn't flat c.copy(C_SAND).lerp(C_SAND_WET, (1 - y * 0.45) * 0.25 + grain * 0.12); } else { - // vegetation: deep jungle in the lows, open glades and dry grass - // patches higher up, occasional dirt breaks - c.copy(C_JUNGLE).lerp(C_JUNGLE_DEEP, n * 0.8); - if (patch > 0.62) c.lerp(C_GRASS, (patch - 0.62) / 0.38 * 0.85); + // vegetation: grassy base broken by glades, dirt, dry highlands — + // then strongly darkened wherever the canopy mask says "jungle", + // which matches exactly where the trees are planted (fake AO) + c.copy(C_JUNGLE).lerp(C_GRASS, patch * 0.7); if (patch < 0.22) c.lerp(C_DIRT, (0.22 - patch) / 0.22 * 0.5); - // higher slopes dry out c.lerp(C_GRASS_DRY, THREE.MathUtils.clamp((elev - 0.45) * 1.6, 0, 0.55) * patch); - // per-island tint variation if (tintShift > 0) c.lerp(C_GRASS_DRY, tintShift); else c.lerp(C_JUNGLE_DEEP, -tintShift); + const canopy = jungleDensityAt(x, z); + c.lerp(C_JUNGLE_DEEP, canopy * (0.55 + n * 0.2)); // blend sand->jungle across a noisy transition band if (y < sandLine + 1.1) c.lerp(C_SAND, (sandLine + 1.1 - y) / 1.1 * 0.85); } @@ -145,9 +145,10 @@ export function buildIslands(scene) { } const merged = mergeGeometries(parts); + // smooth shading: the radial grid has stretched cells, so flat shading + // produced huge ugly facets on open slopes. Colour noise does the styling. const mat = new THREE.MeshStandardMaterial({ vertexColors: true, - flatShading: true, roughness: 1.0, metalness: 0.0, }); diff --git a/pirate-ship/src/vegetation.js b/pirate-ship/src/vegetation.js index bdbbf19..e79ef45 100644 --- a/pirate-ship/src/vegetation.js +++ b/pirate-ship/src/vegetation.js @@ -5,7 +5,7 @@ import * as THREE from 'three'; import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js'; -import { ISLANDS, terrainHeightAt, terrainGradientAt } from './islandField.js'; +import { ISLANDS, terrainHeightAt, terrainGradientAt, jungleDensityAt } from './islandField.js'; import { mulberry32 } from './noise.js'; const TRUNK = new THREE.Color(0x7a5a38); @@ -13,6 +13,8 @@ const TRUNK_D = new THREE.Color(0x5d4329); const FROND_A = new THREE.Color(0x2f8f3c); const FROND_B = new THREE.Color(0x57b54a); const LEAF_DARK = new THREE.Color(0x256e30); +const CANOPY_A = new THREE.Color(0x1c4f24); +const CANOPY_B = new THREE.Color(0x39752f); const COCONUT = new THREE.Color(0x4f3a22); const ROCK_C = new THREE.Color(0x878073); @@ -96,6 +98,28 @@ function makePalmGeometry(rand, height) { return mergeGeometries(parts); } +/** Low leafy shrub: a couple of squashed blobs (reads well from any angle). */ +function makeShrubGeometry(rand, scale) { + const parts = []; + const blobs = 2 + Math.floor(rand() * 2); + for (let b = 0; b < blobs; b++) { + const r = (0.55 + rand() * 0.5) * scale; + // detail 0 (20 tris) is plenty for knee-high shrubs + const blob = new THREE.IcosahedronGeometry(r, 0); + const p = blob.getAttribute('position'); + for (let i = 0; i < p.count; i++) { + const s = 0.8 + rand() * 0.4; + p.setXYZ(i, p.getX(i) * s * 1.1, p.getY(i) * s * 0.55, p.getZ(i) * s * 1.1); + } + blob.computeVertexNormals(); + const a = rand() * Math.PI * 2; + const d = b === 0 ? 0 : (0.4 + rand() * 0.5) * scale; + blob.translate(Math.cos(a) * d, r * 0.4, Math.sin(a) * d); + parts.push(colorize(blob, CANOPY_A, FROND_B)); + } + return mergeGeometries(parts); +} + /** Undergrowth: a star of leafy planes. */ function makeBushGeometry(rand, scale, dark) { const parts = []; @@ -140,25 +164,25 @@ function makeCanopyTreeGeometry(rand, height) { // IcosahedronGeometry blobs are non-indexed; merge requires consistency parts.push(colorize(trunk.toNonIndexed(), TRUNK_D, TRUNK)); - // canopy: 3-4 squashed, jittered leaf blobs + // canopy: wide, flattened, overlapping leaf masses (not "broccoli balls") const blobs = 3 + Math.floor(rand() * 2); for (let b = 0; b < blobs; b++) { - const r = 1.7 + rand() * 1.4; + const r = 2.1 + rand() * 1.6; const blob = new THREE.IcosahedronGeometry(r, 1); const p = blob.getAttribute('position'); for (let i = 0; i < p.count; i++) { - const s = 0.82 + rand() * 0.36; - p.setXYZ(i, p.getX(i) * s, p.getY(i) * s * 0.62, p.getZ(i) * s); + const s = 0.78 + rand() * 0.44; + p.setXYZ(i, p.getX(i) * s * 1.15, p.getY(i) * s * 0.45, p.getZ(i) * s * 1.15); } blob.computeVertexNormals(); const a = rand() * Math.PI * 2; - const d = b === 0 ? 0 : 0.9 + rand() * 1.6; + const d = b === 0 ? 0 : 1.1 + rand() * 2.3; blob.translate( lx * 2.2 + Math.cos(a) * d, - height - 0.4 + (rand() - 0.3) * 1.4, + height - 0.5 + (rand() - 0.35) * 1.6, lz * 2.2 + Math.sin(a) * d ); - parts.push(colorize(blob, LEAF_DARK, FROND_B)); + parts.push(colorize(blob, CANOPY_A, CANOPY_B)); } return mergeGeometries(parts); } @@ -184,7 +208,7 @@ export function buildVegetation(scene, timeUniform) { const bushPts = []; const rockPts = []; for (const isl of ISLANDS) { - const nTry = Math.round((isl.r * isl.r) / 42); + const nTry = Math.round((isl.r * isl.r) / 26); for (let i = 0; i < nTry; i++) { const a = rand() * Math.PI * 2; const r = Math.sqrt(rand()) * isl.r * 1.02; @@ -194,15 +218,18 @@ export function buildVegetation(scene, timeUniform) { terrainGradientAt(x, z, grad); const slope = Math.hypot(grad.x, grad.z); const roll = rand(); - // palms own the coast band; broadleaf canopy fills the interior; - // undergrowth everywhere; rocks near the beaches and clearings - if (y > 1.9 && y < 9 && slope < 0.55 && roll < 0.5) { - palmPts.push({ x, y, z }); - } else if (y > 4.5 && y < isl.height * 0.88 && slope < 0.62 && roll < 0.42) { + // same mask that darkens the ground; small islets get a floor so they + // never end up completely bald + const dens = Math.max(jungleDensityAt(x, z), isl.r < 140 ? 0.55 : 0); + // canopy masses follow the density clusters; palms own the coast band; + // undergrowth fills edges; rocks collect in clearings and beaches + if (y > 3.0 && y < isl.height * 0.9 && slope < 0.66 && dens > 0.45 && roll < 0.55 * dens) { canopyPts.push({ x, y, z }); - } else if (y > 1.6 && slope < 0.75 && roll < 0.55) { + } else if (y > 1.9 && y < 9 && slope < 0.55 && roll < 0.45) { + palmPts.push({ x, y, z }); + } else if (y > 1.6 && slope < 0.75 && roll < 0.35 + dens * 0.3) { bushPts.push({ x, y, z }); - } else if (y > 0.35 && y < 6 && roll < 0.72) { + } else if (y > 0.35 && y < 6 && dens < 0.35 && roll < 0.55) { rockPts.push({ x, y, z }); } } @@ -256,19 +283,19 @@ export function buildVegetation(scene, timeUniform) { sets.push(makeInstances(geo, canopyMat, pts, rand, { minS: 0.75, maxS: 1.5, tilt: 0.08, sink: 0.3 })); }); - // --- undergrowth - const bushGeoA = makeBushGeometry(mulberry32(44), 1.5, false); - const bushGeoB = makeBushGeometry(mulberry32(55), 1.9, true); + // --- undergrowth: leafy shrubs (most) + fern stars (accents) + const shrubGeo = makeShrubGeometry(mulberry32(66), 1.6); + const fernGeo = makeBushGeometry(mulberry32(44), 1.2, false); const bushMat = new THREE.MeshStandardMaterial({ vertexColors: true, flatShading: true, roughness: 1, side: THREE.DoubleSide, }); - const bushA = bushPts.filter((_, i) => i % 2 === 0); - const bushB = bushPts.filter((_, i) => i % 2 === 1); - sets.push(makeInstances(bushGeoA, bushMat, bushA, rand, { minS: 0.7, maxS: 1.5, tilt: 0.12, sink: 0.1 })); - sets.push(makeInstances(bushGeoB, bushMat, bushB, rand, { minS: 0.7, maxS: 1.4, tilt: 0.12, sink: 0.1 })); + const shrubPts = bushPts.filter((_, i) => i % 3 !== 2); + const fernPts = bushPts.filter((_, i) => i % 3 === 2); + sets.push(makeInstances(shrubGeo, bushMat, shrubPts, rand, { minS: 0.7, maxS: 1.6, tilt: 0.1, sink: 0.25 })); + sets.push(makeInstances(fernGeo, bushMat, fernPts, rand, { minS: 0.7, maxS: 1.3, tilt: 0.12, sink: 0.1 })); // --- rocks const rockMat = new THREE.MeshStandardMaterial({ vertexColors: true, flatShading: true, roughness: 1 });