Download index.html from One/Ship: direct link, hf CLI and curl.
- Browser
- Download file 73 kB
-
https://huggingface.co/spaces/One/Ship/resolve/main/index.html
- Command line
-
hf download hf://spaces/One/Ship/index.html
-
curl -L -o index.html https://huggingface.co/spaces/One/Ship/resolve/main/index.html
73 kB
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width,initial-scale=1"> | |
| <title>Putt-Putt on the Great Sea</title> | |
| <style> | |
| html,body{margin:0;height:100%;overflow:hidden;background:#0a2540} | |
| canvas{display:block;width:100vw;height:100vh} | |
| #hud{position:fixed;left:16px;top:14px;color:#e9f5ff;font:12px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace; | |
| text-shadow:0 2px 6px rgba(0,0,0,.45);pointer-events:none} | |
| #hud b{font-size:13px;letter-spacing:.12em} | |
| #hudText{animation:fade 1.2s ease 15s forwards} | |
| @keyframes fade{ to{ opacity:0; visibility:hidden } } | |
| canvas.idle{cursor:none} | |
| #menu{position:fixed;right:16px;top:14px;width:250px;padding:12px 14px;pointer-events:auto; | |
| background:rgba(10,37,64,.86);border:1px solid rgba(233,245,255,.22);border-radius:8px; | |
| color:#e9f5ff;font:12px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace; | |
| text-shadow:0 2px 6px rgba(0,0,0,.45);display:none} | |
| #menu.open{display:block} | |
| #menu .grp>b{letter-spacing:.12em;color:#79d2ff} | |
| #menu label{display:flex;align-items:center;gap:10px;margin-top:5px} | |
| #menu label span{width:54px} | |
| #menu label i{width:34px;text-align:right;font-style:normal;opacity:.7} | |
| </style> | |
| </head> | |
| <body> | |
| <canvas id="c"></canvas> | |
| <div id="hud"> | |
| <div id="hudText"> | |
| <b>PUTT-PUTT ON THE GREAT SEA</b><br> | |
| WASD or arrows throttle · A/D rudder · drag = camera · wheel = zoom · R = reset · M = menu | |
| </div> | |
| </div> | |
| <div id="menu"> | |
| <b>MENU</b> | |
| <div id="menuBody"></div> | |
| </div> | |
| <script> | |
| ; | |
| /* ================= gl ================= */ | |
| const cv = document.getElementById("c"); | |
| const gl = cv.getContext("webgl2", { antialias: true }); | |
| if (!gl) { document.body.innerHTML = "<p style='color:#fff;font-family:monospace;padding:20px'>WebGL2 is required.</p>"; throw new Error("no webgl2"); } | |
| /* ================= math ================= */ | |
| const sub = (a,b)=>[a[0]-b[0],a[1]-b[1],a[2]-b[2]]; | |
| const add = (a,b)=>[a[0]+b[0],a[1]+b[1],a[2]+b[2]]; | |
| const scl = (a,s)=>[a[0]*s,a[1]*s,a[2]*s]; | |
| const cross = (a,b)=>[a[1]*b[2]-a[2]*b[1], a[2]*b[0]-a[0]*b[2], a[0]*b[1]-a[1]*b[0]]; | |
| const dot = (a,b)=>a[0]*b[0]+a[1]*b[1]+a[2]*b[2]; | |
| const norm = v=>{const l=Math.hypot(v[0],v[1],v[2])||1;return [v[0]/l,v[1]/l,v[2]/l];}; | |
| function clamp(v, a, b){ return Math.min(b, Math.max(a, v)); } | |
| function perspective(fov, aspect, near, far){ | |
| const f = 1/Math.tan(fov/2); | |
| return new Float32Array([f/aspect,0,0,0, 0,f,0,0, 0,0,(far+near)/(near-far),-1, 0,0,2*far*near/(near-far),0]); | |
| } | |
| function lookAt(eye, target, up){ | |
| const f = norm(sub(target, eye)); | |
| const r = norm(cross(up, f)); | |
| const u = cross(f, r); | |
| const z = scl(f,-1); | |
| const m = new Float32Array(16); | |
| m[0]=r[0]; m[1]=u[0]; m[2]=z[0]; m[3]=0; | |
| m[4]=r[1]; m[5]=u[1]; m[6]=z[1]; m[7]=0; | |
| m[8]=r[2]; m[9]=u[2]; m[10]=z[2]; m[11]=0; | |
| m[12]=-dot(r,eye); m[13]=-dot(u,eye); m[14]=-dot(z,eye); m[15]=1; | |
| return {m, right:r, up:u, fwd:f}; | |
| } | |
| /* ================= waves (the water surface; mirrored in the shader) ================= | |
| A sea is not a stack of clean sines. Real crests build up and die away in groups over | |
| tens of seconds, the wavelengths share no common factor, and crests are sharper than | |
| troughs. So each system here has its own phase, its own slow amplitude envelope, and a | |
| second harmonic. Nothing repeats exactly, and nothing is finer than the mesh can show. | |
| The JS and the GLSL copies must stay identical or the boat drifts off the water it sits in. */ | |
| function waveH(x, z, t){ | |
| let h = 0; | |
| // dominant swell, long and slow | |
| let a = 0.26 + 0.10*Math.sin(x*0.041 - z*0.037 + t*0.111 + 2.13); | |
| let u = x*0.293 + z*0.059 + t*0.719 + 0.42; | |
| h += a * (Math.sin(u) + 0.20*Math.sin(2.0*u + 0.85)); | |
| // a second system crossing the first at an angle, with its own envelope | |
| a = 0.18 + 0.08*Math.sin(z*0.047 + x*0.023 - t*0.134 + 4.71); | |
| u = z*0.257 - x*0.041 - t*0.983 + 2.94; | |
| h += a * (Math.sin(u) + 0.17*Math.sin(2.0*u + 4.12)); | |
| // a third on a diagonal, so nothing lines up with the grid | |
| a = 0.11 + 0.06*Math.sin((x + z)*0.031 + t*0.167 + 1.37); | |
| u = x*0.611 + z*0.487 + t*1.611 + 5.21; | |
| h += a * (Math.sin(u) + 0.14*Math.sin(2.0*u + 2.33)); | |
| // chop, kept under k = 1.2 so the 1 m mesh can actually resolve it | |
| h += 0.040*Math.sin(x*1.13 - z*0.41 - t*2.21 + 0.93); | |
| h += 0.030*Math.sin(x*0.52 + z*1.09 + t*1.87 + 3.44); | |
| return h; | |
| } | |
| /* ================= islands ================= | |
| Islands come from a hash of the world cell they sit in, so the world has no edge and there | |
| is no island list to maintain: the same cell always gives the same island, and land appears | |
| as you sail into new cells. JS is the only place that decides which islands exist - the | |
| shader is handed the nearby ones as data, so the two can never disagree about whether land | |
| is there at all. The profile below is mirrored in the shader, so the boat sits on the same | |
| ground it is drawn on. */ | |
| const ICELL = 48, MAX_ISLANDS = 96, MAXR = 70; // a 170 m grid plus the reach of the largest island | |
| const LAND = { beach: 0.02, shelf: 1.2, deep: 6.0 }; // beach: how far up the shore the boat may sit | |
| let SEED = 1337; | |
| function ihash(n){ | |
| n >>>= 0; | |
| n ^= n >>> 15; n = Math.imul(n, 0x2c1b3c9d) >>> 0; | |
| n ^= n >>> 12; n = Math.imul(n, 0x297a2d39) >>> 0; | |
| n ^= n >>> 15; return n >>> 0; | |
| } | |
| function frac(v){ return v - Math.floor(v); } | |
| // The land noise uses integer hashing rather than the float hash the sky and the foam use, because | |
| // the boat physically rests on it. Integer arithmetic is exact in both languages, and fround on the | |
| // lattice coordinate means JS and the GPU land on the same lattice cell, so the ground the boat sits | |
| // on is the ground it is drawn on to within about a millionth of a metre. | |
| const F32 = v => Math.fround(v); | |
| function lhash(x, z){ return (ihash((Math.imul(x, 3419) + Math.imul(z, 2147483647)) >>> 0) & 1023) / 1023; } | |
| function lnoise(px, pz){ // takes lattice coordinates, so the caller sets the scale | |
| // Every step is rounded to float32 so this matches what the shader does. The smoothstep and the two | |
| // mixes were previously done in double, which is good enough for a metre grid - but the boat's height | |
| // has to agree with the ground it is drawn on, and the only thing that can put the boat on a | |
| // different height than the mesh is the boat and the shader disagreeing here. | |
| const ix = Math.floor(px), iz = Math.floor(pz); | |
| let fx = F32(px - F32(ix)), fz = F32(pz - F32(iz)); | |
| fx = F32(F32(F32(fx)*F32(fx))*F32(F32(3.0) - F32(F32(2.0)*fx))); | |
| fz = F32(F32(F32(fz)*F32(fz))*F32(F32(3.0) - F32(F32(2.0)*fz))); | |
| const a = lhash(ix, iz), b = lhash(ix+1, iz), c = lhash(ix, iz+1), d = lhash(ix+1, iz+1); | |
| const u = F32(F32(a) + F32(F32(F32(b) - F32(a))*F32(fx))); | |
| const v = F32(F32(c) + F32(F32(F32(d) - F32(c))*F32(fx))); | |
| return F32(F32(u) + F32(F32(F32(v) - F32(u))*F32(fz))); | |
| } | |
| /* code packs four 4-bit fields, because that is the part both sides can decode identically: | |
| bits 0-3 peak, 4-7 profile, 8-11 roughness, 12-15 type. Powers of two are exact in float32, | |
| so the decode cannot drift between JS and the GPU. | |
| type 0 is the common islet: radius 14-30 m, peak 1.2-4.5 m, about 36 cells in 256. | |
| type 1 is a large flattish island: radius 50-70 m, peak 2-7.2 m, about 2 cells in 256. | |
| type 2 is a tall narrow pinnacle: radius 9-18 m, peak 14-38 m, about 3 cells in 256. */ | |
| function islandAt(cx, cz){ | |
| const h = ihash((Math.imul(cx, 73856093) + Math.imul(cz, 19349153) + SEED) >>> 0); | |
| const low = h & 255; | |
| let type = 0; | |
| if(low < 3) type = 1; // rare: a large flattish island | |
| else if(low < 6) return null; // pinnacles removed; these cells stay open sea | |
| else if(low >= 96) return null; // most of the world is open sea | |
| const s = ihash(h) & 0xFFFFFF; | |
| const rb = s & 63, pk = (s >> 6) & 15, sh = (s >> 10) & 15, rg = (s >> 14) & 15; | |
| let r = F32(14 + rb*0.25); // common islet | |
| if(type === 1) r = F32(50 + rb*0.31); // large flattish island | |
| return { | |
| // x, z and r are stored as float32 values because that is what the shader receives. Otherwise | |
| // the two sides can land on different lattice cells and disagree about the island's shape. | |
| x: F32((cx + 0.5 + ((h >>> 8 & 255) - 127.5)/256) * ICELL), | |
| z: F32((cz + 0.5 + ((h >>> 16 & 255) - 127.5)/256) * ICELL), | |
| r, code: pk + sh*16 + rg*256 + type*4096 | |
| }; | |
| } | |
| function decode(code){ | |
| const pk = code & 15, sh = (code >> 4) & 15, rg = (code >> 8) & 15, type = code >> 12; | |
| let peak = F32(F32(1.2) + F32(F32(pk)*F32(0.22))), shape = F32(F32(1.2) + F32(F32(sh)*F32(0.03))), ramp = 0.0; | |
| // shape is the exponent on the falloff, so a small shape is nearly flat. A flat island instead uses a | |
| // ramp - flat for most of the radius, then a straight fall over the last fraction of it, which is the | |
| // beach the boat can actually sit on. Constants are rounded to float32 because the shader computes this | |
| // in float32, and the two have to produce the same number for the same island. | |
| if(type === 1){ peak = F32(F32(2.0) + F32(F32(pk)*F32(0.35))); ramp = F32(F32(0.10) + F32(F32(sh)*F32(0.04))); } | |
| return { peak, shape, rough: F32(F32(rg)/F32(15)), type, ramp, pk, sh, rg }; | |
| } | |
| // smoothstep on [0,1], every step rounded to float32 so the shader and the boat get the same number | |
| function smooth01(u){ return F32(F32(F32(u)*F32(u))*F32(F32(3.0)-F32(F32(2.0)*u))); } | |
| function landH(x, z){ | |
| const cx = Math.floor(x/ICELL), cz = Math.floor(z/ICELL); | |
| // starting below sea level is what makes the depth readable: outside an island there is no | |
| // seabed, so it reads as deep water, and the shallow band around one is the shelf. | |
| // The search has to reach as far as the largest island, otherwise the boat could sit on water | |
| // that the shader is drawing as land. | |
| let h = -LAND.deep; | |
| const span = Math.ceil(MAXR/ICELL); | |
| for(let j=-span;j<=span;j++) for(let i=-span;i<=span;i++){ | |
| const isl = islandAt(cx+i, cz+j); | |
| if(!isl) continue; | |
| const p = decode(isl.code); | |
| // Nothing here is a circle. Two noise lattices are sized to the island itself: one gives the | |
| // outline its lobes, the other the surface its hollows and peaks. Both are sampled relative to | |
| // the island centre so the lattice coordinates stay small, every step is rounded to float32, | |
| // and the offsets are small integers - otherwise the two sides pick different lattice cells and | |
| // disagree about the shape, which is what would put the boat on a different height than the | |
| // ground it is being drawn on. | |
| const gx = F32(x), gz = F32(z); | |
| const f1 = F32(F32(2 + Math.floor(p.rg/4)) / isl.r); | |
| const f2 = F32(F32(6 + p.rg) / isl.r); | |
| const dx = F32(gx - isl.x), dz = F32(gz - isl.z); | |
| const d = F32(Math.sqrt(F32(F32(dx)*F32(dx)) + F32(F32(dz)*F32(dz)))); // what length() does in the shader | |
| const wob = lnoise(F32(F32(dx*f1) + F32(p.pk)), F32(F32(dz*f1) + F32(p.sh))); | |
| const rr = F32(isl.r * F32(F32(0.55) + F32(F32(0.45)*F32(wob)))); | |
| if(d > rr) continue; | |
| const k = F32(F32(1.0) - F32(F32(d)/F32(rr))); | |
| const nz = F32(F32(0.55) + F32(F32(0.45)*F32(p.rough)*lnoise(F32(F32(dx*f2) + F32((p.pk+p.sh+1)%16)), F32(F32(dz*f2) + F32((p.pk+p.sh+7)%16))))); | |
| // A hard min at k = ramp is a crease, and where that crease meets the shoreline it reads as a | |
| // corner. Easing it keeps the flat top - u is clamped to 1 over most of the radius - but rounds the | |
| // shoulder. The shader does the same thing, so the boat still sits on the ground it is drawn on. | |
| const u = F32(F32(k)/F32(p.ramp)); | |
| let s = 1.0; | |
| if(u <= 0.0) s = 0.0; else if(u >= 1.0) s = 1.0; else s = smooth01(u); | |
| const terr = F32(F32(p.ramp > 0 ? s : Math.pow(k, p.shape)) * F32(nz)); | |
| h = Math.max(h, p.peak*terr - LAND.shelf); // the shelf is the shallow ring around an island | |
| } | |
| return h; | |
| } | |
| function landGrad(x, z){ | |
| const e = 0.30; | |
| return [(landH(x+e, z) - landH(x-e, z))/(2*e), (landH(x, z+e) - landH(x, z-e))/(2*e)]; | |
| } | |
| // every island that can reach a vertex of the water grid, extended by the reach of the largest one | |
| function islandsNear(minX, maxX, minZ, maxZ){ | |
| const out = []; | |
| const a = minX - MAXR, b = maxX + MAXR, c = minZ - MAXR, d = maxZ + MAXR; | |
| for(let cz = Math.floor(c/ICELL); cz <= Math.floor(d/ICELL) && out.length < MAX_ISLANDS; cz++) | |
| for(let cx = Math.floor(a/ICELL); cx <= Math.floor(b/ICELL) && out.length < MAX_ISLANDS; cx++){ | |
| const isl = islandAt(cx, cz); | |
| if(!isl) continue; | |
| // Only islands that can actually reach a vertex inside the area the mesh is drawing. Without this | |
| // the list filled up with islands that cannot affect any drawn point, so the ones dropped by the | |
| // cap were still known to the boat - and that is a collision with land you cannot see. | |
| const nx = Math.max(Math.min(isl.x, maxX), minX), nz = Math.max(Math.min(isl.z, maxZ), minZ); | |
| if(Math.hypot(nx - isl.x, nz - isl.z) > isl.r) continue; | |
| out.push(isl); | |
| } | |
| return out; | |
| } | |
| const NOISE_GLSL = ` | |
| float hash(vec2 p){ | |
| p = fract(p * vec2(0.1031, 0.1039)); | |
| p += dot(p, p.yx + 21.83); | |
| return fract((p.x + p.y) * p.x); | |
| } | |
| float vnoise(vec2 p){ | |
| vec2 i = floor(p), f = fract(p); | |
| f = f*f*(3.0-2.0*f); | |
| return mix(mix(hash(i), hash(i+vec2(1.0,0.0)), f.x), | |
| mix(hash(i+vec2(0.0,1.0)), hash(i+vec2(1.0,1.0)), f.x), f.y); | |
| } | |
| float fbm(vec2 p, int oct){ | |
| float v = 0.0, a = 0.5; | |
| for(int i=0;i<oct;i++){ v += a*vnoise(p); p = p*2.03 + vec2(11.3,17.7); a *= 0.5; } | |
| return v; | |
| } | |
| `; | |
| const ISLANDS_GLSL = ` | |
| uniform vec4 uIsl[96]; // must match MAX_ISLANDS, otherwise islands past the cap are silently dropped | |
| uniform int uCount; | |
| uint ihash(uint n){ | |
| n ^= n >> 15; n *= 0x2c1b3c9du; n ^= n >> 12; n *= 0x297a2d39u; n ^= n >> 15; | |
| return n; | |
| } | |
| float lhash(int x, int y){ return float(ihash(uint(x)*3419u + uint(y)*2147483647u) & 1023u) / 1023.0; } | |
| float lnoise(vec2 p){ // takes lattice coordinates, so the caller sets the scale | |
| ivec2 i = ivec2(floor(p)); | |
| vec2 f = p - vec2(float(i.x), float(i.y)); | |
| f = f*f*(3.0-2.0*f); | |
| float a = lhash(i.x, i.y), b = lhash(i.x+1, i.y), c = lhash(i.x, i.y+1), d = lhash(i.x+1, i.y+1); | |
| return mix(mix(a, b, f.x), mix(c, d, f.x), f.y); | |
| } | |
| float landH(vec2 p){ | |
| float h = -6.0; // no land in range: open water, deep enough that depth reads as deep | |
| for(int i=0;i<uCount;i++){ | |
| vec4 isl = uIsl[i]; | |
| vec2 r = p - isl.xy; // island-relative, exactly like the JS copy | |
| float d = length(r); | |
| float code = isl.w; | |
| float pk = mod(code, 16.0), sh = floor(mod(code/16.0, 16.0)), rg = floor(mod(code/256.0, 16.0)); | |
| float type = floor(code/4096.0); | |
| float f1 = (2.0 + floor(rg/4.0)) / isl.z; | |
| float f2 = (6.0 + rg) / isl.z; | |
| float wob = lnoise(r*f1 + vec2(pk, sh)); | |
| float rr = isl.z * (0.55 + 0.45*wob); | |
| if(d > rr) continue; | |
| float peak = 1.2 + pk*0.22, shape = 1.2 + sh*0.03, ramp = 0.0; | |
| if(type == 1.0){ peak = 2.0 + pk*0.35; ramp = 0.10 + sh*0.04; } // ramp was never set here, so the | |
| // shader drew a dome while the boat walked into a flat top with a beach band - two different shapes | |
| // for the same island, which is the collision near the large ones. | |
| else if(type == 2.0){ peak = 14.0 + pk*1.6; shape = 0.04 + sh*0.012; } | |
| float rough = rg/15.0; | |
| float k = 1.0 - d/rr; | |
| float nz = 0.55 + 0.45*rough*lnoise(r*f2 + vec2(mod(pk+sh+1.0, 16.0), mod(pk+sh+7.0, 16.0))); | |
| float terr = (ramp > 0.0) ? smoothstep(0.0, 1.0, k/ramp) * nz : pow(k, shape) * nz; // eased shoulder, mirrored in the JS copy | |
| h = max(h, peak*terr - 1.2); | |
| } | |
| return h; | |
| } | |
| // Land normal from the height field itself rather than from the facet drawn. Bilinear interpolation | |
| // between lattice points is planar, so each cell is a flat sheet and the facet normal makes every quad | |
| // read as its own tile. Sampling the field either side of the point gives a normal that varies | |
| // continuously, which is what stops the islands looking like polygons. | |
| vec3 landN(vec2 p){ | |
| float e = 0.25; | |
| float hx = (landH(p + vec2(e, 0.0)) - landH(p - vec2(e, 0.0))) / (2.0*e); | |
| float hz = (landH(p + vec2(0.0, e)) - landH(p - vec2(0.0, e))) / (2.0*e); | |
| return normalize(vec3(-hx, 1.0, -hz)); | |
| } | |
| `; | |
| /* ================= hull ================= | |
| The water keeps drawing the raw surface, but the boat stops feeling a single point. | |
| A real hull feels the *average* water across its footprint, which averages the short | |
| chop out and leaves only crests longer than the boat. This is JS-only on purpose: | |
| the sea still looks the same, only the boat stops reacting to every little ripple. */ | |
| const LOA = 4.06, BEAM = 1.15; // hull footprint, in world units | |
| const SIT = 0.06; // how high the boat sits on the waterline | |
| const DRAFT = 0.625; // keel depth below the origin - the boat must stay wet | |
| // Heave: a light 4 m hull has a natural heave period well under a second, so it should follow the | |
| // surface closely. The hull-averaged waterplane is already what removes the chop - the spring should | |
| // not have to do it as well, which is why the period is short and the damping only moderate. A | |
| // critically damped spring cannot overshoot, so it can never settle onto a crest the way a real | |
| // light boat does; zeta under 1 is what lets it ring once or twice and come to rest. | |
| const HEAVE = {period: 0.45, zeta: 0.40}; | |
| // Pitch and roll have different natural periods on a small hull. Roll is quick: the waterplane slope | |
| // across a 1.15 m beam changes faster than the slope along the length. Pitch lags more. | |
| const TILT = {roll:{period:0.6, zeta:0.40}, pitch:{period:1.2, zeta:0.45}}; | |
| // On land there is nothing to ring. Both springs go critically damped, and the tilt comes from the | |
| // ground under the hull instead of from the waterplane. | |
| const GROUND = {heave:{period:0.4, zeta:1.0}, tilt:{roll:{period:1.0, zeta:1.0}, pitch:{period:1.2, zeta:1.0}}}; | |
| const STATIONS = []; | |
| (function(){ | |
| for(let u = -LOA/2; u <= LOA/2; u += 0.5) | |
| for(let v = -BEAM/2; v <= BEAM/2; v += 0.25) STATIONS.push([u, v]); | |
| })(); | |
| // what is actually under a point: the ground if it is above sea level, otherwise the sea. | |
| // Same rule the mesh draws with, which is what keeps the boat on the surface it is sitting on. | |
| // Land only counts where the ground is above the water, because that is the land you can see. | |
| function surfaceH(x, z, t){ const g = landH(x, z), w = waveH(x, z, t); return Math.max(g, w); } | |
| // average height under the footprint. u runs along the keel, v across the beam. | |
| // The average is weighted towards the middle of the waterplane. A flat average over the whole | |
| // footprint is what a rigid hull would do for long swell, but it cancels nearly everything for a wave | |
| // about the length of the boat - that is what was making it read as half-buoyant. A real hull is not | |
| // rigid: the middle carries the motion and the ends ride over the top of it, so the weight falls off | |
| // towards the ends. Long swell still averages to the local height, so that part is unchanged. | |
| function hullH(x, z, heading, t){ | |
| const ax = Math.sin(heading), az = Math.cos(heading); | |
| let sum = 0, wsum = 0; | |
| for(const s of STATIONS){ | |
| const w = (1 - (s[0]/(LOA/2))**2) * (1 - (s[1]/(BEAM/2))**2); | |
| if(w <= 0) continue; | |
| sum += w * surfaceH(x + ax*s[0] + az*s[1], z + az*s[0] - ax*s[1], t); | |
| wsum += w; | |
| } | |
| return sum / wsum; | |
| } | |
| // The highest water over the footprint. A rigid hull rides the mean of the waterplane, but the deck | |
| // is not allowed to be under the sea, so the heave target is pulled a little towards the high point | |
| // instead of being the pure mean. That is what stops a crest under the stern swallowing the deck. | |
| function hullTop(x, z, heading, t){ | |
| const ax = Math.sin(heading), az = Math.cos(heading); | |
| let m = -1e9; | |
| for(const s of STATIONS) m = Math.max(m, waveH(x + ax*s[0] + az*s[1], z + az*s[0] - ax*s[1], t)); | |
| return m; | |
| } | |
| // highest ground under the footprint. A rigid flat-bottomed hull rests on the high point of a | |
| // slope rather than on the average of it, so grounding has to use the max, not the mean. | |
| function hullLand(x, z, heading){ | |
| const ax = Math.sin(heading), az = Math.cos(heading); | |
| let m = -1e9; | |
| for(const s of STATIONS) m = Math.max(m, landH(x + ax*s[0] + az*s[1], z + az*s[0] - ax*s[1])); | |
| return m; | |
| } | |
| // Is there land above the water at a point? Same test the mesh uses to decide what to draw, so the | |
| // boat only meets land it can see. It is checked at the boat itself rather than as the highest point | |
| // over the whole footprint - that stopped a 4 m hull about two metres before the water's edge. The | |
| // boat can now nose onto the beach and slide back off. | |
| function shoreAt(x, z, t){ return landH(x, z) - waveH(x, z, t); } | |
| // same window, water only. A floating boat is tipped by the waterplane, not by ground under it - the | |
| // version above mixes land into the average, so a corner that sees land pulls the whole tilt up. When | |
| // the boat is actually aground the ground slope is used instead. | |
| function hullWater(x, z, heading, t){ | |
| const ax = Math.sin(heading), az = Math.cos(heading); | |
| let sum = 0, wsum = 0; | |
| for(const s of STATIONS){ | |
| const w = (1 - (s[0]/(LOA/2))**2) * (1 - (s[1]/(BEAM/2))**2); | |
| if(w <= 0) continue; | |
| const px = x + ax*s[0] + az*s[1], pz = z + az*s[0] - ax*s[1]; | |
| sum += w * waveH(px, pz, t); | |
| wsum += w; | |
| } | |
| return sum / wsum; | |
| } | |
| // slope of that averaged water: the boat tips by the difference across its own length/beam | |
| function hullSlope(x, z, heading, t){ | |
| const ax = Math.sin(heading), az = Math.cos(heading); | |
| const fore = hullWater(x + ax*LOA/2, z + az*LOA/2, heading, t); | |
| const aft = hullWater(x - ax*LOA/2, z - az*LOA/2, heading, t); | |
| const star = hullWater(x + az*BEAM/2, z - ax*BEAM/2, heading, t); | |
| const port = hullWater(x - az*BEAM/2, z + ax*BEAM/2, heading, t); | |
| return [(fore - aft) / LOA, (star - port) / BEAM]; | |
| } | |
| // ground slope across the footprint. The tilt follows whatever surface is under the hull, but a | |
| // grounded boat is pulled by the slope of the ground itself, not of the water above it. | |
| function hullGroundSlope(x, z, heading){ | |
| const ax = Math.sin(heading), az = Math.cos(heading); | |
| const g = (u, v) => landH(x + ax*u + az*v, z + az*u - ax*v); | |
| return [(g(LOA/2, 0) - g(-LOA/2, 0)) / LOA, (g(0, BEAM/2) - g(0, -BEAM/2)) / BEAM]; | |
| } | |
| /* A damped spring: the boat eases toward the water instead of snapping to it. */ | |
| function spring(pos, vel, target, dt, period, zeta){ | |
| const w = 2*Math.PI/period; | |
| vel += (w*w*(target - pos) - 2*zeta*w*vel) * dt; | |
| return [pos + vel*dt, vel]; | |
| } | |
| /* ================= shaders ================= */ | |
| const WAVES_GLSL = ` | |
| float waveH(vec2 p, float t){ | |
| float h = 0.0; | |
| float a = 0.26 + 0.10*sin(p.x*0.041 - p.y*0.037 + t*0.111 + 2.13); | |
| float u = p.x*0.293 + p.y*0.059 + t*0.719 + 0.42; | |
| h += a * (sin(u) + 0.20*sin(2.0*u + 0.85)); | |
| a = 0.18 + 0.08*sin(p.y*0.047 + p.x*0.023 - t*0.134 + 4.71); | |
| u = p.y*0.257 - p.x*0.041 - t*0.983 + 2.94; | |
| h += a * (sin(u) + 0.17*sin(2.0*u + 4.12)); | |
| a = 0.11 + 0.06*sin((p.x + p.y)*0.031 + t*0.167 + 1.37); | |
| u = p.x*0.611 + p.y*0.487 + t*1.611 + 5.21; | |
| h += a * (sin(u) + 0.14*sin(2.0*u + 2.33)); | |
| h += 0.040*sin(p.x*1.13 - p.y*0.41 - t*2.21 + 0.93); | |
| h += 0.030*sin(p.x*0.52 + p.y*1.09 + t*1.87 + 3.44); | |
| return h; | |
| } | |
| // finite differences instead of a hand-written derivative: it always matches whatever | |
| // waveH actually drew, and sampling a little way out slightly smooths the facets | |
| vec3 waveN(vec2 p, float t){ | |
| float e = 0.20; | |
| float dx = (waveH(p + vec2(e, 0.0), t) - waveH(p - vec2(e, 0.0), t)) / (2.0*e); | |
| float dz = (waveH(p + vec2(0.0, e), t) - waveH(p - vec2(0.0, e), t)) / (2.0*e); | |
| return normalize(vec3(-dx, 1.0, -dz)); | |
| } | |
| `; | |
| const waterP = prog(`#version 300 es | |
| precision highp float; | |
| in vec2 aGrid; | |
| uniform mat4 uProj, uView; | |
| uniform vec2 uOrigin; | |
| uniform float uTime, uSpeed, uPopAge; | |
| uniform vec3 uBoat, uFwd, uPopPos; | |
| out vec3 vWorld; out vec3 vNormal; out float vFoam; out float vLand, vDepth, vEdge; | |
| ${WAVES_GLSL} | |
| ${NOISE_GLSL} | |
| ${ISLANDS_GLSL} | |
| void main(){ | |
| vec2 w = aGrid + uOrigin; | |
| vec3 d = vec3(w.x - uBoat.x, 0.0, w.y - uBoat.z); | |
| float r = length(d); | |
| float behind = -dot(d, uFwd); | |
| float side = abs(cross(uFwd, d).y); | |
| float wake = 0.0; | |
| if(behind > 0.35 && uSpeed > 0.05){ | |
| float wid = 0.45 + behind*0.16; | |
| wake = smoothstep(wid, 0.0, side) * exp(-behind*0.055) * clamp(uSpeed/3.5, 0.0, 1.0); | |
| wake *= 0.55 + 0.45*sin(behind*2.1); | |
| wake *= 0.62 + 0.38*fbm(w*0.30 + uTime*0.04, 2); // breaks the wedge into streaks | |
| } | |
| float hullFoam = smoothstep(2.2, 0.4, r) * (0.10 + 0.70*clamp(uSpeed/2.5, 0.0, 1.0)); | |
| float dip = 0.14 * smoothstep(2.6, 0.0, r); | |
| float ripple = 0.0, popFoam = 0.0; | |
| if(uPopAge > 0.0 && uPopAge < 0.8){ | |
| float rr = length(w - uPopPos.xz); | |
| float ringR = 0.15 + uPopAge*3.0; | |
| float g = exp(-((rr-ringR)*(rr-ringR))/0.10) * exp(-uPopAge*4.0); | |
| ripple = -0.05 * g; | |
| popFoam = 0.30 * g; | |
| } | |
| // the same rule as the boat: the surface is whichever is higher, the sea or the ground. | |
| // Under water this is the sea as before; on land it is the terrain, and the line where they | |
| // cross is the coastline. | |
| // The sea and the ground are not the same kind of surface. The wave only exists where there is | |
| // water: once the ground is above sea level it is solid and does not move with the swell. Using | |
| // max(land, water) here made low land near the shore ride the waves like a liquid. | |
| // Land only where the ground is above the water. Below the surface the ground is not drawn at all, | |
| // so the boat must not be stopped by it, and the coastline becomes where the water actually meets | |
| // the land rather than where a fixed height is crossed. | |
| float land = landH(w); | |
| float water = waveH(w, uTime) - dip + ripple; | |
| float edge = land - water; // signed distance to the shoreline, interpolated in the fragment stage | |
| bool isLand = edge > 0.0; | |
| float h = isLand ? land : water; | |
| vec3 wp = vec3(w.x, h, w.y); | |
| vWorld = wp; | |
| vLand = land; | |
| vDepth = water - land; // depth of water over the seabed | |
| vEdge = edge; | |
| vNormal = isLand ? landN(w) : waveN(w, uTime); // only land vertices pay for the extra samples | |
| vFoam = clamp((wake + hullFoam + popFoam) * (isLand ? 0.0 : 1.0), 0.0, 1.0); | |
| gl_Position = uProj * uView * vec4(wp, 1.0); | |
| }`, `#version 300 es | |
| precision highp float; | |
| in vec3 vWorld; in vec3 vNormal; in float vFoam; in float vLand, vDepth, vEdge; | |
| uniform vec3 uCam; | |
| uniform float uTime; | |
| uniform vec3 uSand, uGrass, uRock; | |
| out vec4 frag; | |
| ${NOISE_GLSL} | |
| const vec3 LDIR = vec3(0.5, 0.75, -0.45); | |
| float toneOf(float ndl){ | |
| float q = clamp((ndl - 0.30) / 0.55, 0.0, 1.0) * 4.0; | |
| return mix(floor(q)/4.0, q/4.0, 0.40); | |
| } | |
| vec3 landCol(vec3 n, float lv){ | |
| float tone = toneOf(max(dot(n, LDIR), 0.0)); | |
| float flatness = clamp(n.y, 0.0, 1.0); // "flat" is a reserved word in GLSL ES 3.0 | |
| vec3 col = mix(uSand * 0.62, uSand, smoothstep(0.0, 0.30, lv)); // wet sand at the waterline | |
| col = mix(col, uGrass, smoothstep(0.40, 1.10, lv) * smoothstep(0.50, 0.75, flatness)); | |
| col = mix(col, uRock, smoothstep(0.55, 0.80, 1.0 - flatness)); | |
| col = col * (0.55 + 0.70 * tone); | |
| col = mix(col, col * 1.10, (vnoise(vWorld.xz * 2.2) - 0.5) * 0.35 * smoothstep(0.2, 1.0, lv)); | |
| return col; | |
| } | |
| vec3 waterCol(vec3 n){ | |
| float ndl = max(dot(n, LDIR), 0.0); | |
| float tone = toneOf(ndl); | |
| vec3 deep = vec3(0.04,0.17,0.38); | |
| vec3 mid = vec3(0.09,0.34,0.53); | |
| vec3 lite = vec3(0.22,0.56,0.68); | |
| vec3 col = mix(deep, mid, smoothstep(0.55, 0.70, tone)); | |
| col = mix(col, lite, smoothstep(0.82, 0.97, tone)); | |
| float bed = fbm(vWorld.xz * 0.035, 3); | |
| col = mix(col, mid * 1.14 + vec3(0.02,0.05,0.04), smoothstep(0.55, 0.85, bed) * 0.30); | |
| col = mix(col, uSand * 0.80, smoothstep(1.40, 0.15, vDepth) * 0.28); | |
| float contour = smoothstep(0.775, 0.785, ndl) + smoothstep(0.655, 0.665, ndl); | |
| col = mix(col, lite, clamp(contour, 0.0, 1.0) * 0.45); | |
| float cap = smoothstep(0.34, 0.44, length(n.xz)); | |
| col = mix(col, vec3(0.92,0.97,1.0), cap * 0.30); | |
| vec3 V = normalize(uCam - vWorld); | |
| float g = pow(max(dot(n, normalize(LDIR + V)), 0.0), 12.0); | |
| float path = smoothstep(0.004, 0.03, g); | |
| float dots = smoothstep(0.72, 0.84, vnoise(vWorld.xz * 3.0 + uTime * 0.05)); | |
| col = mix(col, vec3(0.96,0.99,1.0), clamp(path * (0.25 + 0.60*dots), 0.0, 1.0)); | |
| return col; | |
| } | |
| void main(){ | |
| vec3 fn = normalize(cross(dFdy(vWorld), dFdx(vWorld))); // the facet actually drawn | |
| if(fn.y < 0.0) fn = -fn; | |
| vec3 wn = normalize(vNormal); | |
| // The land-or-water decision used to be made once per facet, so a facet that straddled the shoreline | |
| // took one material for the whole triangle and the edge became a chain of straight segments along the | |
| // mesh - a staircase on a 1 m grid. It is now an interpolated value, so the crossing is a line across | |
| // each facet that joins with its neighbours, and the narrow band between the two is blended instead of | |
| // switched. | |
| // The normal comes from the height field, not from the facet, so land shades continuously across the | |
| // mesh instead of one flat tile per quad. fn is still used, but only to decide which material a facet | |
| // is - a facet this steep can only be a cliff face, and the sea surface never gets that steep. | |
| float t = clamp(smoothstep(-0.12, 0.12, vEdge), 0.0, 1.0); | |
| if(fn.y < 0.12) t = 1.0; | |
| vec3 n = mix(wn, normalize(vNormal), t); | |
| if(n.y < 0.0) n = -n; | |
| vec3 col = mix(waterCol(wn), landCol(fn, max(vLand, 0.0)), t); | |
| float ndl = max(dot(n, LDIR), 0.0); | |
| float tone = toneOf(ndl); | |
| // warm on the sun side, the same trick as the clouds | |
| col = mix(col, vec3(0.98,0.90,0.80), smoothstep(0.55, 0.95, ndl) * smoothstep(0.45, 0.80, tone) * 0.18); | |
| // wispy foam, built the same way as the clouds, plus a band where shallow water meets the shore | |
| float wisps = smoothstep(0.42, 0.58, fbm(vWorld.xz * 1.2 + uTime * 0.02, 2)); | |
| float wet = 1.0 - t; | |
| float shore = smoothstep(0.50, 0.02, max(vDepth, 0.0)) * 0.22 * wet; | |
| float f = clamp(vFoam * wet * (0.35 + 0.55 * wisps) + shore, 0.0, 1.0); | |
| col = mix(col, vec3(0.93,0.98,1.0), f); | |
| float fog = smoothstep(14.0, 70.0, length(vWorld - uCam)); | |
| col = mix(col, vec3(0.35,0.60,0.78), fog); | |
| frag = vec4(col, 1.0); | |
| }`); | |
| const boatP = prog(`#version 300 es | |
| precision highp float; | |
| in vec3 aPos; in vec3 aCol; in vec3 aNormal; in float aGlow, aMat; | |
| uniform mat4 uProj, uView, uModel; | |
| uniform vec3 uOrigin; | |
| out vec3 vPos; out vec3 vCol; out vec3 vNormal; out float vGlow, vMat; | |
| void main(){ | |
| vec3 wp = (uModel * vec4(aPos,1.0)).xyz + uOrigin; | |
| vPos = wp; vCol = aCol; vNormal = normalize(aNormal); vGlow = aGlow; vMat = aMat; | |
| gl_Position = uProj * uView * vec4(wp, 1.0); | |
| }`, `#version 300 es | |
| precision highp float; | |
| in vec3 vPos; in vec3 vCol; in vec3 vNormal; in float vGlow, vMat; | |
| uniform vec3 uCam; | |
| out vec4 frag; | |
| void main(){ | |
| vec3 n = normalize(vNormal); | |
| vec3 v = normalize(uCam - vPos); | |
| if(dot(n, v) < 0.0) n = -n; | |
| vec3 L = normalize(vec3(0.5, 0.75, -0.45)); | |
| float ndl = max(dot(n, L), 0.0); | |
| float k = max(0.52 + 0.48 * floor(ndl*4.0)/4.0, vGlow); // vGlow lets the flame ignore the sun | |
| vec3 col = vCol * k; | |
| // a little bounce from the sky and from the water, so the underside is not flat black | |
| col += vCol * 0.13 * max(0.0, -n.y) + vCol * 0.10 * max(0.0, n.y); | |
| // brass and steel get a tight highlight; paint, wood and cloth do not | |
| float spec = pow(max(dot(n, normalize(v + L)), 0.0), 26.0) * vMat; | |
| col += vec3(1.0, 0.97, 0.90) * spec * ndl * 0.60; | |
| float rim = smoothstep(0.30, 0.0, max(dot(n, v), 0.0)); | |
| col = mix(col, col*0.34, rim*0.6); | |
| float fog = smoothstep(14.0, 70.0, length(vPos - uCam)); | |
| col = mix(col, vec3(0.35,0.60,0.78), fog); | |
| frag = vec4(col, 1.0); | |
| }`); | |
| const puffP = prog(`#version 300 es | |
| precision highp float; | |
| in vec3 aPos; in vec2 aUV; in float aAlpha, aKind, aSeed; | |
| uniform mat4 uProj, uView; | |
| out vec2 vUV; out float vA; out float vKind, vSeed; | |
| void main(){ | |
| vUV = aUV; vA = aAlpha; vKind = aKind; vSeed = aSeed; | |
| gl_Position = uProj * uView * vec4(aPos, 1.0); | |
| }`, `#version 300 es | |
| precision highp float; | |
| in vec2 vUV; in float vA; in float vKind, vSeed; | |
| out vec4 frag; | |
| void main(){ | |
| // a slightly irregular outline, so a puff is not a perfect circle | |
| float ang = atan(vUV.y, vUV.x); | |
| float wob = 1.0 + 0.05*sin(ang*3.0 + vSeed*6.283) + 0.03*sin(ang*5.0 - vSeed*6.283); | |
| float r = length(vUV); | |
| if(r > wob) discard; | |
| float a = vA * (1.0 - smoothstep(0.86, 1.0, r/wob)); | |
| // kind 0 = steam above the water, kind 1 = the water jet breaking the surface | |
| vec3 body = mix(vec3(0.94,0.96,0.98), vec3(0.84,0.94,0.99), vKind); | |
| vec3 edge = mix(vec3(0.44,0.62,0.76), vec3(0.52,0.72,0.85), vKind); | |
| vec3 c = mix(body, edge, smoothstep(0.55, 0.98, r) * 0.75); | |
| // the small highlight is what makes a blob read as a bubble rather than a smudge | |
| float hi = smoothstep(0.34, 0.0, length(vUV - vec2(-0.26, 0.30))); | |
| c = mix(c, vec3(1.0), hi * 0.40 * (1.0 - smoothstep(0.0, 1.0, r))); | |
| frag = vec4(c, clamp(a * (1.0 + hi*0.30), 0.0, 1.0)); | |
| }`); | |
| const skyP = prog(`#version 300 es | |
| precision highp float; | |
| in vec2 aClip; | |
| out vec2 vClip; | |
| void main(){ vClip = aClip; gl_Position = vec4(aClip, 0.999, 1.0); }`, `#version 300 es | |
| precision highp float; | |
| in vec2 vClip; | |
| uniform vec3 uRight, uUp, uFwd; | |
| uniform vec2 uTan; | |
| uniform float uTime; | |
| out vec4 frag; | |
| float hash(vec2 p){ // cheap hash, no sin - this is the whole cost of the sky | |
| p = fract(p * vec2(0.1031, 0.1039)); | |
| p += dot(p, p.yx + 21.83); | |
| return fract((p.x + p.y) * p.x); | |
| } | |
| float vnoise(vec2 p){ | |
| vec2 i = floor(p), f = fract(p); | |
| f = f*f*(3.0-2.0*f); | |
| return mix(mix(hash(i), hash(i+vec2(1.0,0.0)), f.x), | |
| mix(hash(i+vec2(0.0,1.0)), hash(i+vec2(1.0,1.0)), f.x), f.y); | |
| } | |
| float fbm(vec2 p, int oct){ | |
| float v = 0.0, a = 0.5; | |
| for(int i=0;i<oct;i++){ v += a*vnoise(p); p = p*2.03 + vec2(11.3,17.7); a *= 0.5; } | |
| return v; | |
| } | |
| void main(){ | |
| vec3 d = normalize(uRight*(vClip.x*uTan.x) + uUp*(vClip.y*uTan.y) + uFwd); | |
| float h = clamp(d.y, 0.0, 1.0); | |
| float band = floor(h*6.0)/6.0; | |
| vec3 col = mix(vec3(0.35,0.60,0.78), vec3(0.22,0.45,0.72), band); | |
| if(d.y > 0.02){ | |
| // the ray hits a cloud layer overhead. That gives real perspective - clouds get small | |
| // towards the horizon instead of being noise painted flat onto a dome. | |
| vec2 p = d.xz / max(d.y, 0.05) * 6.0 + uTime * vec2(0.08, 0.05); | |
| vec2 w = vec2(vnoise(p*0.25 + 3.1), vnoise(p*0.25 + 7.7)); // warped input = curling, wispy edges | |
| float mass = fbm(p*0.28 + (w-0.5)*1.8, 3); // the big shapes | |
| float wisps = fbm(p*1.10 + (w-0.5)*2.4, 2); // the detail inside them | |
| float cov = 0.72*mass + 0.28*wisps; | |
| float T0 = 0.415, T1 = 0.48; // where the clouds start: about a fifth of the sky | |
| float shape = smoothstep(T0, T1, cov) * smoothstep(0.02, 0.22, d.y); | |
| // posterised tones so the clouds match the flat shading used everywhere else | |
| float th = clamp((cov - T0) / (0.62 - T0), 0.0, 1.0); | |
| float q = th * 4.0; | |
| float tone = mix(floor(q)/4.0, q/4.0, 0.40); // blended steps, not hard bands | |
| vec3 cc = mix(vec3(0.62,0.74,0.84), vec3(0.86,0.92,0.96), smoothstep(0.05, 0.55, tone)); | |
| cc = mix(cc, vec3(0.97,0.98,0.99), smoothstep(0.62, 0.90, tone)); | |
| cc = mix(cc, vec3(0.98,0.99,1.00), smoothstep(T0, T1, cov) * (1.0 - smoothstep(T1, T1+0.05, cov)) * 0.5); // lit edge | |
| vec3 L = normalize(vec3(0.5, 0.75, -0.45)); | |
| cc = mix(cc, vec3(0.98,0.90,0.80), smoothstep(0.55, 0.95, dot(d, L)) * smoothstep(0.45, 0.80, th) * 0.35); | |
| col = mix(col, cc, clamp(shape, 0.0, 1.0)); | |
| } | |
| frag = vec4(col, 1.0); | |
| }`); | |
| function prog(vs, fs){ | |
| const p = gl.createProgram(); | |
| const v = gl.createShader(gl.VERTEX_SHADER); gl.shaderSource(v, vs); gl.compileShader(v); | |
| if(!gl.getShaderParameter(v, gl.COMPILE_STATUS)) console.error("vs", gl.getShaderInfoLog(v)); | |
| const f = gl.createShader(gl.FRAGMENT_SHADER); gl.shaderSource(f, fs); gl.compileShader(f); | |
| if(!gl.getShaderParameter(f, gl.COMPILE_STATUS)) console.error("fs", gl.getShaderInfoLog(f)); | |
| gl.attachShader(p, v); gl.attachShader(p, f); gl.linkProgram(p); | |
| if(!gl.getProgramParameter(p, gl.LINK_STATUS)) console.error("link", gl.getProgramInfoLog(p)); | |
| const u = {}, a = {}; | |
| const nu = gl.getProgramParameter(p, gl.ACTIVE_UNIFORMS); | |
| for(let i=0;i<nu;i++){ const info = gl.getActiveUniform(p, i); u[info.name] = gl.getUniformLocation(p, info.name); } | |
| const na = gl.getProgramParameter(p, gl.ACTIVE_ATTRIBUTES); | |
| for(let i=0;i<na;i++){ const info = gl.getActiveAttrib(p, i); a[info.name] = gl.getAttribLocation(p, info.name); } | |
| return {p, u, a}; | |
| } | |
| /* ================= geometry ================= */ | |
| const CELL = 1.0, N = 170; | |
| const gridBuf = gl.createBuffer(), gridIdx = gl.createBuffer(); | |
| (function(){ | |
| const verts = new Float32Array((N+1)*(N+1)*2); | |
| let k = 0; | |
| for(let j=0;j<=N;j++) for(let i=0;i<=N;i++){ verts[k++] = (i-N/2)*CELL; verts[k++] = (j-N/2)*CELL; } | |
| const idx = []; | |
| const id = (i,j)=> j*(N+1)+i; | |
| for(let j=0;j<N;j++) for(let i=0;i<N;i++){ | |
| idx.push(id(i,j), id(i,j+1), id(i+1,j+1), id(i,j), id(i+1,j+1), id(i+1,j)); | |
| } | |
| gl.bindBuffer(gl.ARRAY_BUFFER, gridBuf); gl.bufferData(gl.ARRAY_BUFFER, verts, gl.STATIC_DRAW); | |
| gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, gridIdx); gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(idx), gl.STATIC_DRAW); | |
| })(); | |
| const boatBuf = gl.createBuffer(); | |
| // uniform arrays are set element by element, so these locations are looked up once | |
| const islLoc = []; | |
| for(let i=0;i<MAX_ISLANDS;i++) islLoc.push(gl.getUniformLocation(waterP.p, "uIsl[" + i + "]")); | |
| const puffBuf = gl.createBuffer(); | |
| const skyBuf = gl.createBuffer(); | |
| const maxIndex = (N+1)*(N+1) - 1; | |
| if (maxIndex > 65535) console.error("index buffer too big for Uint16"); | |
| // attribute state is applied per draw instead of cached in a VAO | |
| function setAttribs(pr, layout, stride){ | |
| for (const [name, size, offset] of layout){ | |
| const loc = pr.a[name]; | |
| if (loc === undefined) { console.log("missing attribute:", name); continue; } | |
| gl.enableVertexAttribArray(loc); | |
| gl.vertexAttribPointer(loc, size, gl.FLOAT, false, stride, offset); | |
| } | |
| } | |
| gl.bindBuffer(gl.ARRAY_BUFFER, skyBuf); | |
| gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 3,-1, -1,3]), gl.STATIC_DRAW); | |
| /* ================= boat mesh (rebuilt each frame for the tiller/arm/flag) ================= */ | |
| /* The palette lives in two copies: BASE is what the sliders start from, PAL is the live one | |
| the mesh draws with. The menu writes into ctl and calls paint(), so colours change live. */ | |
| const BASE = { | |
| hull:[0.85,0.83,0.76], stripe:[0.78,0.26,0.25], wood:[0.56,0.39,0.24], | |
| metal:[0.42,0.45,0.48], skin:[0.86,0.70,0.55], shirt:[0.25,0.47,0.66], hat:[0.90,0.83,0.60], | |
| brass:[0.72,0.55,0.28], wax:[0.94,0.90,0.80], flame:[1.00,0.62,0.22], | |
| jacket:[0.86,0.44,0.24], | |
| sand:[0.82,0.74,0.55], grass:[0.45,0.62,0.40], rock:[0.52,0.52,0.50] | |
| }; | |
| const PAL = {}; | |
| const ctl = { all: 0, tint: {} }; | |
| function shiftHue(rgb, deg){ | |
| if(!deg) return rgb.slice(); | |
| const mx = Math.max(...rgb), mn = Math.min(...rgb); | |
| if(mx === mn) return rgb.slice(); // greyscale has no hue to turn | |
| const l = (mx + mn) / 2; | |
| const s = l > 0.5 ? (mx - mn) / (2 - mx - mn) : (mx - mn) / (mx + mn); | |
| let h; | |
| if(mx === rgb[0]) h = (rgb[1] - rgb[2]) / (mx - mn); | |
| else if(mx === rgb[1]) h = (rgb[2] - rgb[0]) / (mx - mn) + 2; | |
| else h = (rgb[0] - rgb[1]) / (mx - mn) + 4; | |
| h = ((h * 60 + deg) % 360 + 360) % 360; | |
| const c = (1 - Math.abs(2*l - 1)) * s; | |
| const x = c * (1 - Math.abs(((h/60) % 2) - 1)); | |
| let r=0, g=0, b=0; | |
| if(h < 60){ r=c; g=x; } else if(h < 120){ r=x; g=c; } | |
| else if(h < 180){ g=c; b=x; } else if(h < 240){ g=x; b=c; } | |
| else if(h < 300){ r=x; b=c; } else { r=c; b=x; } | |
| const m = l - c/2; | |
| return [r+m, g+m, b+m]; | |
| } | |
| function paint(){ | |
| for(const k in BASE) PAL[k] = shiftHue(BASE[k], ctl.all + (ctl.tint[k] || 0)); | |
| } | |
| function setPal(pr, names){ | |
| for(const n of names) gl.uniform3f(pr.u["u" + n], PAL[n][0], PAL[n][1], PAL[n][2]); | |
| } | |
| let boatVerts = []; | |
| const BOAT_SCALE = 1.25; | |
| // heat = how close the candle is to the boiler, which is what W/S is really controlling | |
| function buildBoat(rudder, t, heat){ | |
| const S = BOAT_SCALE, TAU = Math.PI*2; | |
| const faces = [], acc = new Map(); | |
| // Curved parts get averaged normals, so a rounded hull reads as rounded instead of as a stack | |
| // of quads. Flat parts (transom, deck, hat brim) keep their own normal - that contrast is what | |
| // makes a flat surface still read as flat. | |
| const vk = p => (Math.round(p[0]*512)+1024)*4194304 + (Math.round(p[1]*512)+1024)*2048 + (Math.round(p[2]*512)+1024); | |
| const emit=(pts, col, f, glow, mat, smooth)=>{ | |
| faces.push([pts, col, f, glow, mat, smooth]); | |
| if(smooth) for(const p of pts){ const k=vk(p), a=acc.get(k)||[0,0,0]; acc.set(k,[a[0]+f[0],a[1]+f[1],a[2]+f[2]]); } | |
| }; | |
| const tri=(a,b,c,col,glow=0,mat=0,smooth=false)=>emit([a,b,c], col, norm(cross(sub(b,a),sub(c,a))), glow, mat, smooth); | |
| const quad=(a,b,c,d,col,glow=0,mat=0,smooth=false)=>{ tri(a,b,c,col,glow,mat,smooth); tri(a,c,d,col,glow,mat,smooth); }; | |
| const box=(cx,cy,cz,sx,sy,sz,col,mat=0)=>{ | |
| const x0=cx-sx,x1=cx+sx,y0=cy-sy,y1=cy+sy,z0=cz-sz,z1=cz+sz; | |
| quad([x0,y0,z0],[x1,y0,z0],[x1,y1,z0],[x0,y1,z0],col,0,mat); | |
| quad([x0,y0,z1],[x1,y0,z1],[x1,y1,z1],[x0,y1,z1],col,0,mat); | |
| quad([x0,y0,z0],[x0,y1,z0],[x0,y1,z1],[x0,y0,z1],col,0,mat); | |
| quad([x1,y0,z0],[x1,y1,z0],[x1,y1,z1],[x1,y0,z1],col,0,mat); | |
| quad([x0,y1,z0],[x1,y1,z0],[x1,y1,z1],[x0,y1,z1],col,0,mat); | |
| quad([x0,y0,z0],[x1,y0,z0],[x1,y0,z1],[x0,y0,z1],col,0,mat); | |
| }; | |
| const disc=(cx,cy,cz,r,n,col,mat=0)=>{ | |
| for(let i=0;i<n;i++){ const a1=i/n*TAU, a2=(i+1)/n*TAU; | |
| tri([cx+Math.cos(a1)*r,cy,cz+Math.sin(a1)*r],[cx+Math.cos(a2)*r,cy,cz+Math.sin(a2)*r],[cx,cy,cz],col,0,mat); } | |
| }; | |
| const cone=(cx,cy,cz,r,h,n,col,glow=0,mat=0)=>{ | |
| for(let i=0;i<n;i++){ const a1=i/n*TAU, a2=(i+1)/n*TAU; | |
| tri([cx+Math.cos(a1)*r,cy,cz+Math.sin(a1)*r],[cx+Math.cos(a2)*r,cy,cz+Math.sin(a2)*r],[cx,cy+h,cz],col,glow,mat); } | |
| }; | |
| const sphere=(cx,cy,cz,r,col,mat=0,seg=8,rows=5)=>{ | |
| const P=(a,b)=>[cx+Math.cos(a)*Math.cos(b)*r, cy+Math.sin(a)*r, cz+Math.cos(a)*Math.sin(b)*r]; | |
| for(let j=0;j<rows;j++) for(let i=0;i<seg;i++){ | |
| const a0=Math.PI*(-0.5+j/rows), a1=Math.PI*(-0.5+(j+1)/rows), b0=i/seg*TAU, b1=(i+1)/seg*TAU; | |
| quad(P(a0,b0),P(a0,b1),P(a1,b1),P(a1,b0),col,0,mat,true); | |
| } | |
| }; | |
| const tube=(a,b,r,n,col,glow=0,mat=0,smooth=true)=>{ | |
| const d=norm(sub(b,a)); | |
| let u=cross(d,[0,1,0]); if(Math.hypot(u[0],u[1],u[2])<1e-4) u=cross(d,[1,0,0]); u=norm(u); | |
| const v=norm(cross(d,u)); | |
| for(let i=0;i<n;i++){ const a1=i/n*TAU, a2=(i+1)/n*TAU; | |
| const A1=add(a,add(scl(u,Math.cos(a1)*r),scl(v,Math.sin(a1)*r))); | |
| const A2=add(a,add(scl(u,Math.cos(a2)*r),scl(v,Math.sin(a2)*r))); | |
| const B1=add(b,add(scl(u,Math.cos(a1)*r),scl(v,Math.sin(a1)*r))); | |
| const B2=add(b,add(scl(u,Math.cos(a2)*r),scl(v,Math.sin(a2)*r))); | |
| quad(A1,A2,B2,B1,col,glow,mat,smooth); | |
| } | |
| }; | |
| const rod=(a,b,th,col,glow=0,mat=0)=>tube(a,b,th,6,col,glow,mat,true); | |
| const limb=(a,b,r,col,mat=0)=>tube(a,b,r,6,col,0,mat,true); | |
| const ellipsoid=(cx,cy,cz,rx,ry,rz,col,mat=0,seg=10,rows=5)=>{ | |
| const P=(a,b)=>[cx+Math.cos(a)*Math.cos(b)*rx, cy+Math.sin(a)*ry, cz+Math.cos(a)*Math.sin(b)*rz]; | |
| for(let j=0;j<rows;j++) for(let i=0;i<seg;i++){ | |
| const a0=Math.PI*(-0.5+j/rows), a1=Math.PI*(-0.5+(j+1)/rows), b0=i/seg*TAU, b1=(i+1)/seg*TAU; | |
| quad(P(a0,b0),P(a0,b1),P(a1,b1),P(a1,b0),col,0,mat,true); | |
| } | |
| }; | |
| const poly=(pts,r,col,glow=0,mat=0)=>{ for(let i=0;i<pts.length-1;i++) tube(pts[i], pts[i+1], r, 7, col, glow, mat, true); }; | |
| // a two link arm solved for where the hand has to be, so the elbow bends as the thing being | |
| // held moves. Straight rods from shoulder to handle were what read as an awkward arm. | |
| const arm=(sh, target, L1, L2, bendDir)=>{ | |
| const d = sub(target, sh), len = Math.max(1e-4, Math.hypot(d[0],d[1],d[2])); | |
| const u = scl(d, 1/len); | |
| const ca = Math.acos(clamp((L1*L1 + len*len - L2*L2)/(2*L1*len), -1, 1)); | |
| let b = sub(bendDir, scl(u, dot(bendDir, u))); | |
| if(Math.hypot(b[0],b[1],b[2]) < 1e-4) b = [0,-1,0]; else b = norm(b); | |
| const elbow = add(sh, add(scl(u, L1*Math.cos(ca)), scl(b, L1*Math.sin(ca)))); | |
| limb(sh, elbow, 0.05, PAL.jacket); | |
| limb(elbow, target, 0.042, PAL.skin); | |
| sphere(target[0], target[1], target[2], 0.045, PAL.skin, 0, 8, 4); | |
| }; | |
| // ---- hull ---- | |
| // The profile is a function of position along the boat instead of a list of stations, so the | |
| // sides are continuous, the widest point is amidships like a real hull, the sheer rises towards | |
| // the bow and the keel comes up as it goes forward. The stripe band is the topsides just under | |
| // the rub rail, which is what the plain stripe quad used to be standing in for. | |
| const wAt = z => z < 0 ? 0.42 + 0.04*Math.sin(clamp((z+1.55)/1.55,0,1)*1.5708) | |
| : 0.46*Math.pow(1 - clamp(z/1.45,0,1), 1.35); | |
| const dAt = z => 0.22 + 0.14*Math.pow(clamp((z+1.55)/3.2,0,1), 1.7); // freeboard at the transom raised | |
| const bAt = z => -0.50 + 0.34*Math.pow(clamp((z+1.55)/3.2,0,1), 2.2); | |
| const ring = z => { | |
| const w = wAt(z), d = dAt(z), b = bAt(z), sy = d - 0.07, by = b + 0.16*(d-b); | |
| return [[[-w,d,z], PAL.stripe], [[-w*0.94,sy,z], PAL.stripe], | |
| [[-w*0.86,by,z], PAL.hull], [[-w*0.14,b,z], PAL.hull], | |
| [[ w*0.14,b,z], PAL.hull], [[ w*0.86,by,z], PAL.hull], | |
| [[ w*0.94,sy,z], PAL.stripe], [[ w,d,z], PAL.stripe]]; | |
| }; | |
| const Z0=-1.55, Z1=1.45, STEPS=18; | |
| for(let s=0;s<STEPS;s++){ | |
| const z0=Z0+(Z1-Z0)*s/STEPS, z1=Z0+(Z1-Z0)*(s+1)/STEPS; | |
| const r0=ring(z0), r1=ring(z1); | |
| for(let i=0;i<r0.length-1;i++) quad(r0[i][0], r1[i][0], r1[i+1][0], r0[i+1][0], r0[i][1], 0, 0, true); | |
| quad(r0[7][0], r0[0][0], r1[0][0], r1[7][0], PAL.wood); // deck | |
| } | |
| // transom: fill the whole last cross section, including the closing edge across the top. | |
| // Skipping that edge left a gap you could see straight through. | |
| const rT = ring(Z0), tc = [0, (dAt(Z0)+bAt(Z0))/2, Z0]; | |
| for(let i=0;i<rT.length;i++) tri(rT[i][0], rT[(i+1)%rT.length][0], tc, rT[i][1]); | |
| // bow: fan the whole last cross section to the stem | |
| const rr = ring(Z1), tip = [0, dAt(1.70), 1.70]; | |
| for(let i=0;i<rr.length;i++) tri(rr[i][0], rr[(i+1)%rr.length][0], tip, rr[i][1], 0, 0, true); | |
| // ---- boiler, candle, exhaust ---- | |
| const bz = 0.08, by = 0.36; | |
| ellipsoid(0, by, bz, 0.16, 0.05, 0.16, PAL.brass, 1, 10, 4); | |
| for(const side of [-1, 1]){ | |
| // the pipes are what hold the boiler up: they come out of its underside, dip below the | |
| // waterline just aft of it - that dip is the U bend the water sits in - and the mouth | |
| // stays submerged at the back. | |
| poly([[side*0.12, by-0.01, bz-0.02], [side*0.15, 0.10, -0.14], [side*0.19, -0.16, -0.62], | |
| [side*0.22, -0.26, -1.42], [side*0.24, -0.34, -1.82]], 0.035, PAL.metal, 0, 1); | |
| } | |
| // the candle stands on the deck under the boiler and slides forward towards it, so at full | |
| // throttle the flame is just under the boiler's underside, where the heat actually does something | |
| const cz = -0.32 + 0.30*heat; | |
| const cy = dAt(cz); // on the deck, not floating above it | |
| const flame = 0.03 + 0.02*heat + 0.008*Math.sin(t*11.0) + 0.005*Math.sin(t*23.0); | |
| tube([0, cy, cz], [0, cy+0.035, cz], 0.035, 7, PAL.wax, 0, 0, true); | |
| cone(0, cy+0.035, cz, 0.035, flame, 7, PAL.flame, 1.0, 0); | |
| const knob = [-0.05, cy+0.022, cz]; // the little handle on the candle's side | |
| sphere(knob[0], knob[1], knob[2], 0.018, [0.30,0.28,0.24], 0, 6, 3); | |
| // ---- tiller and rudder ---- | |
| const ang = -rudder * 0.45; | |
| const head = [0, 0.16, -1.58]; | |
| const dx = Math.sin(ang), dz = Math.cos(ang); | |
| const hx = dx*0.70, hz = head[2] + dz*0.70, hy = 0.40; | |
| rod(head, [hx, hy, hz], 0.022, [0.30,0.28,0.24], 0, 1); | |
| rod(head, [head[0], -0.06, head[2]], 0.018, [0.30,0.33,0.36], 0, 1); | |
| const bx = head[0]+dx*0.40, bz2 = head[2]+dz*0.40; | |
| quad([head[0],-0.06,head[2]], [bx,-0.06,bz2], [bx,-0.36,bz2], [head[0],-0.36,head[2]], PAL.metal, 0, 1); | |
| // ---- helmsman ---- | |
| // Off centre to port, so the arm reaches the tiller straight ahead instead of across the boat. | |
| const PX = -0.14; | |
| const hip = [PX, 0.34, -0.46], chest = [PX, 0.56, -0.52]; | |
| box(PX, 0.26, -0.54, 0.15, 0.04, 0.19, PAL.wood); | |
| limb(hip, [PX+0.02, 0.30, -0.24], 0.055, PAL.shirt); // thigh | |
| limb([PX+0.02, 0.30, -0.24], [PX+0.03, 0.21, -0.14], 0.045, PAL.skin); // shin | |
| tube(hip, chest, 0.125, 8, PAL.jacket, 0, 0, true); // life jacket | |
| sphere(chest[0], chest[1]+0.01, chest[2], 0.125, PAL.jacket, 0, 10, 4); // rounded shoulders | |
| limb(chest, [PX, 0.64, -0.54], 0.05, PAL.skin); // neck | |
| sphere(PX, 0.70, -0.55, 0.095, PAL.skin, 0, 10, 6); // head | |
| disc(PX, 0.79, -0.55, 0.15, 10, PAL.hat); // brim | |
| cone(PX, 0.79, -0.55, 0.115, 0.10, 10, PAL.hat); // crown | |
| // right hand on the tiller, left hand on the candle handle. Both are solved for wherever the | |
| // thing being held currently is, so pushing the throttle straightens the left arm as the candle | |
| // moves forward, and steering swings the right one. | |
| arm([PX+0.06, 0.52, -0.48], [hx, hy, hz], 0.29, 0.29, [-0.55,-0.55,-0.1]); | |
| arm([PX-0.06, 0.52, -0.44], knob, 0.29, 0.29, [-0.5,-0.6,0.05]); | |
| // ---- pennant ---- | |
| // the pole starts at the deck, not above it | |
| rod([0, dAt(1.20), 1.20], [0, 0.95, 1.20], 0.02, [0.35,0.30,0.22], 0, 1); | |
| const fl = 0.05*Math.sin(t*6.0) + 0.02; | |
| tri([0,0.95,1.20],[0,0.86,1.20+fl],[0,0.92,1.20+fl*0.4], PAL.stripe); | |
| const m = []; | |
| for(const f of faces){ | |
| for(const p of f[0]){ | |
| const a = acc.get(vk(p)); | |
| // a vertex whose accumulated normal cancels out is a degenerate face, so fall back to the face | |
| const n = (f[5] && a && Math.hypot(a[0],a[1],a[2]) > 1e-6) ? norm(a) : f[2]; | |
| m.push(p[0]*S, p[1]*S, p[2]*S, f[1][0], f[1][1], f[1][2], n[0], n[1], n[2], f[3], f[4]); | |
| } | |
| } | |
| boatVerts = m; | |
| } | |
| /* ================= audio ================= */ | |
| let AC=null, master=null, engineGain=null, hissGain=null, scrapeGain=null, noiseBuf=null, hum1=null, hum2=null; | |
| function initAudio(){ | |
| if(AC) return; | |
| try{ AC = new (window.AudioContext||window.webkitAudioContext)(); } catch(e){ return; } | |
| master = AC.createGain(); master.gain.value = 0.55; master.connect(AC.destination); | |
| noiseBuf = makeNoise(1.0); | |
| const o1 = AC.createOscillator(); o1.type="sawtooth"; o1.frequency.value=46; | |
| const o2 = AC.createOscillator(); o2.type="sine"; o2.frequency.value=71; | |
| const lp = AC.createBiquadFilter(); lp.type="lowpass"; lp.frequency.value=210; lp.Q.value=0.8; | |
| engineGain = AC.createGain(); engineGain.gain.value = 0.012; | |
| o1.connect(lp); lp.connect(engineGain); o2.connect(engineGain); engineGain.connect(master); | |
| hum1 = o1; hum2 = o2; | |
| o1.start(); o2.start(); | |
| const src = AC.createBufferSource(); src.buffer = noiseBuf; src.loop = true; | |
| const bp = AC.createBiquadFilter(); bp.type="bandpass"; bp.frequency.value=650; bp.Q.value=0.7; | |
| hissGain = AC.createGain(); hissGain.gain.value = 0.005; | |
| src.connect(bp); bp.connect(hissGain); hissGain.connect(master); src.start(); | |
| // hull on shingle: the same noise, pushed down and kept quiet until it is actually moving | |
| const src2 = AC.createBufferSource(); src2.buffer = noiseBuf; src2.loop = true; | |
| const lp2 = AC.createBiquadFilter(); lp2.type="lowpass"; lp2.frequency.value=190; lp2.Q.value=0.6; | |
| scrapeGain = AC.createGain(); scrapeGain.gain.value = 0.0; | |
| src2.connect(lp2); lp2.connect(scrapeGain); scrapeGain.connect(master); src2.start(); | |
| } | |
| function makeNoise(sec){ | |
| const b = AC.createBuffer(1, Math.floor(AC.sampleRate*sec), AC.sampleRate); | |
| const d = b.getChannelData(0); | |
| for(let i=0;i<d.length;i++) d[i] = Math.random()*2-1; | |
| return b; | |
| } | |
| function pop(strength){ | |
| if(!AC) return; | |
| const t = AC.currentTime; | |
| const o = AC.createOscillator(); o.type="sine"; | |
| const base = (95 + 40*strength) * (0.85 + Math.random()*0.3); | |
| o.frequency.setValueAtTime(base, t); | |
| o.frequency.exponentialRampToValueAtTime(38, t+0.14); | |
| const g = AC.createGain(); | |
| g.gain.setValueAtTime(0.0001, t); | |
| g.gain.exponentialRampToValueAtTime(0.32*strength + 0.02, t+0.015); | |
| g.gain.exponentialRampToValueAtTime(0.0001, t+0.22); | |
| o.connect(g); g.connect(master); o.start(t); o.stop(t+0.25); | |
| const s = AC.createBufferSource(); s.buffer = noiseBuf; | |
| const bp = AC.createBiquadFilter(); bp.type="bandpass"; bp.frequency.value=900; bp.Q.value=1.2; | |
| const cg = AC.createGain(); | |
| cg.gain.setValueAtTime(0.0001, t); | |
| cg.gain.exponentialRampToValueAtTime(0.10*strength + 0.005, t+0.006); | |
| cg.gain.exponentialRampToValueAtTime(0.0001, t+0.09); | |
| s.connect(bp); bp.connect(cg); cg.connect(master); s.start(t); s.stop(t+0.10); | |
| } | |
| /* ================= input ================= */ | |
| const keys = {}; | |
| /* Arrows are aliases, not a second set of state. One map used by both press and release, | |
| so a key can never leave a control stuck on. */ | |
| const KEYMAP = { arrowup:"w", arrowdown:"s", arrowleft:"a", arrowright:"d" }; | |
| const keyOf = e => KEYMAP[e.key.toLowerCase()] || e.key.toLowerCase(); | |
| addEventListener("keydown", e=>{ | |
| initAudio(); if(AC && AC.state==="suspended") AC.resume(); | |
| const onSlider = e.target && e.target.tagName === "INPUT"; | |
| const k = keyOf(e); | |
| // arrows belong to whatever has focus: a focused slider keeps its arrow keys, otherwise | |
| // they steer (and would otherwise scroll the page) | |
| if(["w","a","s","d"].includes(k) && !onSlider) keys[k] = true; | |
| if(k === "r") reset(); | |
| if(k === "m") toggleMenu(); | |
| if(e.key.startsWith("Arrow") && !onSlider) e.preventDefault(); | |
| }); | |
| addEventListener("keyup", e=>{ keys[keyOf(e)] = false; }); | |
| let dragYaw=0, dragPitch=0, dist=7.0, dragging=false, lastX=0, lastY=0; | |
| // Right-drag is the camera, so the browser's right-click menu has to be switched off. | |
| // It also swallows the button release, which is why the drag can stay stuck on after the menu closes. | |
| cv.addEventListener("contextmenu", e=>{ e.preventDefault(); }); | |
| cv.addEventListener("pointerdown", e=>{ | |
| if(e.button === 1) return; // middle button does nothing; left and right drag the camera | |
| dragging = true; | |
| lastX = e.clientX; lastY = e.clientY; | |
| cv.setPointerCapture(e.pointerId); // still get the release if the pointer drifts off the canvas | |
| initAudio(); | |
| }); | |
| const stopDrag = e=>{ | |
| if(!dragging) return; | |
| dragging = false; | |
| try{ cv.releasePointerCapture(e.pointerId); }catch(err){} | |
| }; | |
| cv.addEventListener("pointerup", stopDrag); | |
| cv.addEventListener("pointercancel", stopDrag); | |
| // A cursor cannot be transitioned - it is drawn by the browser, not by the page - so it can only | |
| // appear or disappear. It comes back on any movement, and stays put while you are dragging. | |
| let cursorTimer = null; | |
| function showCursor(){ | |
| cv.classList.remove("idle"); | |
| clearTimeout(cursorTimer); | |
| cursorTimer = setTimeout(()=>{ if(!dragging) cv.classList.add("idle"); }, 1200); | |
| } | |
| cv.addEventListener("pointermove", showCursor); | |
| cv.addEventListener("pointerleave", ()=>{ clearTimeout(cursorTimer); cv.classList.remove("idle"); }); | |
| cv.addEventListener("pointerdown", showCursor); | |
| addEventListener("pointermove", e=>{ | |
| if(!dragging) return; | |
| dragYaw += (e.clientX-lastX)*0.006; | |
| dragPitch = clamp(dragPitch + (e.clientY-lastY)*0.004, -0.15, 1.1); | |
| lastX = e.clientX; lastY = e.clientY; | |
| }); | |
| addEventListener("wheel", e=>{ dist = clamp(dist + e.deltaY*0.01, 4, 22); }, {passive:true}); | |
| /* ================= menu ================= | |
| Controls are described as data rather than hand-written markup: a label, a range, and a | |
| get/set pair. The menu itself has no idea what any of them do, so anything added later is | |
| one entry in this list. */ | |
| const menuEl = document.getElementById("menu"); | |
| const menuBody = document.getElementById("menuBody"); | |
| let menuOpen = false; | |
| const MENU = [ | |
| { name: "boat colours", controls: [ | |
| { label: "all", min: -180, max: 180, get: () => ctl.all, set: v => { ctl.all = v; paint(); } }, | |
| ...Object.keys(BASE).map(k => ({ | |
| label: k, min: -180, max: 180, | |
| get: () => ctl.tint[k] || 0, set: v => { ctl.tint[k] = v; paint(); } | |
| })) | |
| ] } | |
| ]; | |
| function buildMenu(){ | |
| for(const group of MENU){ | |
| const g = document.createElement("div"); | |
| const h = document.createElement("b"); h.textContent = group.name; g.appendChild(h); | |
| for(const c of group.controls){ | |
| const row = document.createElement("label"); | |
| const name = document.createElement("span"); name.textContent = c.label; | |
| const val = document.createElement("i"); val.textContent = c.get(); | |
| const s = document.createElement("input"); | |
| s.type = "range"; s.min = c.min; s.max = c.max; s.step = c.step || 1; s.value = c.get(); | |
| s.addEventListener("input", () => { c.set(Number(s.value)); val.textContent = s.value; }); | |
| row.append(name, s, val); | |
| g.appendChild(row); | |
| } | |
| menuBody.appendChild(g); | |
| } | |
| } | |
| function toggleMenu(){ menuOpen = !menuOpen; menuEl.classList.toggle("open", menuOpen); } | |
| buildMenu(); | |
| paint(); | |
| /* ================= state ================= */ | |
| const boat = {x:0, z:0, heading:0, throttle:0, rudder:0, speed:0, | |
| bob:0, bobV:0, tiltX:0, tiltXV:0, tiltZ:0, tiltZV:0, shudder:0, aground:false}; | |
| const cam = {yaw:0, pitch:0.35, shake:0}; | |
| let puffs = [], nextPop = 0, popIdx = 0, last = 0, lastPopT = -9, popPos = [0,0,0]; | |
| /* R has to put the boat in water, so it looks for some: a widening ring of candidates, with a | |
| different set of angles on each ring so it does not crawl the same line forever. */ | |
| function findWater(){ | |
| for(let ring = 8; ring < 200; ring += 8){ | |
| for(let k = 0; k < 12; k++){ | |
| const a = k*Math.PI/6 + ring*0.37; | |
| const x = boat.x + Math.cos(a)*ring, z = boat.z + Math.sin(a)*ring; | |
| if(hullLand(x, z, boat.heading) < -0.15) return [x, z]; // a little water under the whole hull | |
| } | |
| } | |
| return [boat.x, boat.z]; | |
| } | |
| function reset(){ | |
| const p = findWater(); | |
| boat.x=p[0];boat.z=p[1];boat.heading=0;boat.throttle=0;boat.rudder=0;boat.speed=0;boat.aground=false; | |
| boat.bob=0;boat.bobV=0;boat.tiltX=0;boat.tiltXV=0;boat.tiltZ=0;boat.tiltZV=0;boat.shudder=0; | |
| } | |
| function spawnPuff(model, origin, fwd, side, heat){ | |
| if(puffs.length > 160) return; | |
| const lp = [side*0.24*BOAT_SCALE, -0.34*BOAT_SCALE, -1.82*BOAT_SCALE]; // the submerged tube mouth | |
| const wp = [ | |
| origin[0] + model[0]*lp[0] + model[4]*lp[1] + model[8]*lp[2], | |
| origin[1] + model[1]*lp[0] + model[5]*lp[1] + model[9]*lp[2], | |
| origin[2] + model[2]*lp[0] + model[6]*lp[1] + model[10]*lp[2] | |
| ]; | |
| popPos = wp; | |
| const kind = landH(wp[0], wp[2]) > 0.02 ? 0 : 1; // a tube above the shore puts out steam, not foam | |
| const push = 0.15 + 0.45*heat; // more heat, harder the water is pushed out | |
| puffs.push({ | |
| p:wp, seed:Math.random(), | |
| v:[-fwd[0]*push + (Math.random()-0.5)*0.25, 0.35 + Math.random()*0.25, -fwd[2]*push + (Math.random()-0.5)*0.25], | |
| life:0, max:0.8 + Math.random()*0.4, size:0.03 + 0.03*heat, kind | |
| }); | |
| } | |
| /* ================= loop ================= */ | |
| function resize(){ | |
| const dpr = Math.min(2, window.devicePixelRatio || 1); | |
| const w = Math.max(1, Math.floor(innerWidth*dpr)), h = Math.max(1, Math.floor(innerHeight*dpr)); | |
| if(cv.width!==w || cv.height!==h){ cv.width=w; cv.height=h; } | |
| gl.viewport(0,0,w,h); | |
| } | |
| function frame(nowMs){ | |
| const t = nowMs/1000; | |
| const dt = Math.min(0.05, t - last); last = t; | |
| resize(); | |
| /* --- boat physics --- */ | |
| // exponential smoothing: same feel at 60fps and at 144fps | |
| const thrTarget = (keys.w?1:0) - (keys.s?1:0); | |
| boat.throttle += (thrTarget - boat.throttle) * (1 - Math.exp(-dt*2.5)); | |
| const rudTarget = (keys.a?1:0) - (keys.d?1:0); | |
| boat.rudder += (rudTarget - boat.rudder) * (1 - Math.exp(-dt*4.0)); | |
| const maxSpeed = 6.0, idleThrust = 0.35; // the candle is always heating, so it always pulls a little | |
| /* --- what is under the hull --- */ | |
| const landAt = landH(boat.x, boat.z); // ground under the boat itself | |
| const surf = hullWater(boat.x, boat.z, boat.heading, t); // the waterplane, not the ground under it | |
| // The boat only meets ground that is above the water, because that is the land the mesh draws. Under | |
| // the surface the seabed is invisible, so it must neither stop the boat nor tip it. A hull in half a | |
| // metre of water over an unseen bottom was being treated as aground, and the tilt came from the slope | |
| // of ground you cannot see - which is the lean you are seeing close to shores. | |
| const shore = shoreAt(boat.x, boat.z, t); | |
| const grounded = shore > LAND.beach; | |
| const wetted = grounded ? 0 : 1; | |
| boat.aground = grounded; // dry land: nothing to hiss against | |
| // A putt-putt pushes against water. Out of the water it only spins, so the engine is scaled by how | |
| // much of the hull is still wet. What moves a grounded boat is gravity along the hull - and since | |
| // the profile always falls away towards the sea, that always points back to water. | |
| // On the ground the tilt and the slide both come from the slope of the ground itself; a boat | |
| // resting on the beach should not be tipped by a wave it is no longer in. | |
| let sx, sz; | |
| if(wetted < 0.5) [sx, sz] = hullGroundSlope(boat.x, boat.z, boat.heading); | |
| else [sx, sz] = hullSlope(boat.x, boat.z, boat.heading, t); | |
| const drive = (idleThrust + boat.throttle*(maxSpeed - idleThrust)) * wetted; | |
| const slide = -sx * 2.5 * (1 - wetted); // bow on higher ground means it slides back off | |
| boat.speed += ((drive - boat.speed) * 2.2 + slide) * dt; | |
| const authority = clamp(0.15 + Math.abs(boat.speed)/3.0, 0.15, 1.0) * wetted; // no water over the rudder | |
| boat.heading += boat.rudder * 1.4 * authority * dt; | |
| let nx = boat.x + Math.sin(boat.heading) * boat.speed * dt; | |
| let nz = boat.z + Math.cos(boat.heading) * boat.speed * dt; | |
| // It may sit on the beach, but it cannot climb. The motion into the land is removed and the rest | |
| // slides along the contour, which is what stops it sticking fast in a cove. | |
| if(shoreAt(nx, nz, t) > LAND.beach){ | |
| const g = norm([landGrad(nx, nz)[0], 0, landGrad(nx, nz)[1]]); | |
| const mv = [nx - boat.x, 0, nz - boat.z]; | |
| const into = dot(mv, g); | |
| if(into > 0){ | |
| const along = sub(mv, scl(g, into)); | |
| nx = boat.x + along[0]; nz = boat.z + along[2]; | |
| } | |
| } | |
| boat.x = nx; boat.z = nz; | |
| /* --- float: the hull feels the average surface over its footprint, not one point --- */ | |
| // The boat cannot sink through the bottom, so the target is the higher of normal buoyancy and the | |
| // ground plus the draft - but only where the ground is above sea level. The seabed is never drawn: | |
| // the water is opaque and the surface mesh follows the waves, not the bottom, so a hull that grounds | |
| // on a seabed a metre below the surface reads as floating above invisible ground. Only land the mesh | |
| // actually draws is physical. | |
| const floatTarget = Math.max(surf + 0.12*(hullTop(boat.x, boat.z, boat.heading, t) - surf) + SIT, grounded ? landAt + DRAFT : -1e9); | |
| const hs = wetted < 0.5 ? GROUND.heave : HEAVE; | |
| const ts = wetted < 0.5 ? GROUND.tilt : TILT; | |
| [boat.bob, boat.bobV] = spring(boat.bob, boat.bobV, floatTarget, dt, hs.period, hs.zeta); | |
| [boat.tiltX, boat.tiltXV] = spring(boat.tiltX, boat.tiltXV, sx, dt, ts.roll.period, ts.roll.zeta); | |
| [boat.tiltZ, boat.tiltZV] = spring(boat.tiltZ, boat.tiltZV, sz, dt, ts.pitch.period, ts.pitch.zeta); | |
| const up = norm([-boat.tiltX, 1, -boat.tiltZ]); | |
| const heading = [Math.sin(boat.heading), 0, Math.cos(boat.heading)]; | |
| const fwd = norm(sub(heading, scl(up, dot(heading, up)))); | |
| const right = norm(cross(up, fwd)); | |
| const rot = (v, ang) => norm(add(scl(v, Math.cos(ang)), scl(cross(fwd, v), Math.sin(ang)))); | |
| boat.shudder *= Math.exp(-dt*7); | |
| const bank = clamp(boat.rudder*authority, -1, 1) * 0.14 + boat.shudder*0.45*Math.sin(t*35); | |
| const upB = rot(up, bank); | |
| const rightB = rot(right, bank); | |
| const model = new Float32Array(16); // columns = right, up, fwd (column-major) | |
| model[0]=rightB[0]; model[1]=rightB[1]; model[2]=rightB[2]; | |
| model[4]=upB[0]; model[5]=upB[1]; model[6]=upB[2]; | |
| model[8]=fwd[0]; model[9]=fwd[1]; model[10]=fwd[2]; | |
| model[15]=1; | |
| const origin = [boat.x, boat.bob + boat.shudder*0.03, boat.z]; | |
| /* --- the candle heats the chamber: slow even putts at rest, a gallop as it gets hotter --- */ | |
| if(t >= nextPop){ | |
| const heat = Math.abs(boat.throttle); | |
| const strength = 0.18 + heat*0.8; | |
| const side = popIdx % 2 ? 1 : -1; // the two tubes fire one after the other | |
| pop(strength * (side === 1 ? 1.0 : 0.7)); | |
| popIdx++; | |
| spawnPuff(model, origin, fwd, side, heat); | |
| boat.shudder += 0.05*strength; | |
| cam.shake += 0.006*strength; | |
| lastPopT = t; | |
| const rpm = 0.55 + heat*3.2; | |
| // even beats at rest; the classic putt-putt gallop only appears once it is hot enough | |
| const beat = side === 1 ? 1.0 : (0.55 + 0.45*(1 - Math.min(1, heat))); | |
| nextPop = t + beat/rpm; | |
| } | |
| cam.shake *= Math.exp(-dt*5); | |
| /* --- camera --- */ | |
| cam.yaw += ((boat.heading + dragYaw) - cam.yaw) * Math.min(1, dt*3.0); | |
| cam.pitch += ((0.25 + dragPitch) - cam.pitch) * Math.min(1, dt*3.0); | |
| const cp = cam.pitch + cam.shake*0.4; | |
| const dir = [Math.cos(cp)*Math.sin(cam.yaw), -Math.sin(cp), Math.cos(cp)*Math.cos(cam.yaw)]; | |
| const target = [boat.x, boat.bob + 0.45, boat.z]; | |
| // the camera used to ride its own private bit of chop; keep only a little of it | |
| const eye = [target[0]-dir[0]*dist, target[1]-dir[1]*dist + waveH(target[0]-dir[0]*dist, target[2]-dir[2]*dist, t)*0.10, target[2]-dir[2]*dist]; | |
| const view = lookAt(eye, target, [0,1,0]); | |
| const aspect = cv.width / cv.height; | |
| const proj = perspective(0.9, aspect, 0.1, 400); | |
| const tanHalf = [Math.tan(0.45)*aspect, Math.tan(0.45)]; | |
| /* --- draw --- */ | |
| gl.clearColor(0.22,0.45,0.72,1); | |
| gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); | |
| gl.disable(gl.DEPTH_TEST); | |
| gl.useProgram(skyP.p); | |
| gl.uniform3f(skyP.u.uRight, view.right[0], view.right[1], view.right[2]); | |
| gl.uniform3f(skyP.u.uUp, view.up[0], view.up[1], view.up[2]); | |
| gl.uniform3f(skyP.u.uFwd, view.fwd[0], view.fwd[1], view.fwd[2]); | |
| gl.uniform2f(skyP.u.uTan, tanHalf[0], tanHalf[1]); | |
| gl.uniform1f(skyP.u.uTime, t); | |
| gl.bindBuffer(gl.ARRAY_BUFFER, skyBuf); setAttribs(skyP, [["aClip",2,0]], 0); gl.drawArrays(gl.TRIANGLES, 0, 3); | |
| gl.enable(gl.DEPTH_TEST); | |
| gl.useProgram(waterP.p); | |
| gl.uniformMatrix4fv(waterP.u.uProj, false, proj); | |
| gl.uniformMatrix4fv(waterP.u.uView, false, view.m); | |
| const ax = Math.floor(boat.x/CELL)*CELL; | |
| const az = Math.floor(boat.z/CELL)*CELL; | |
| gl.uniform2f(waterP.u.uOrigin, ax, az); | |
| // the islands the grid can actually reach, as data - the shader never decides whether land exists | |
| const isl = islandsNear(ax - N*CELL/2, ax + N*CELL/2, az - N*CELL/2, az + N*CELL/2); | |
| gl.uniform1i(waterP.u.uCount, isl.length); | |
| for(let i=0;i<MAX_ISLANDS;i++){ | |
| const v = isl[i] ? [isl[i].x, isl[i].z, isl[i].r, isl[i].code] : [0,0,0,0]; | |
| gl.uniform4f(islLoc[i], v[0], v[1], v[2], v[3]); | |
| } | |
| setPal(waterP, ["sand", "grass", "rock"]); | |
| gl.uniform1f(waterP.u.uTime, t); | |
| gl.uniform1f(waterP.u.uSpeed, Math.abs(boat.speed)); | |
| gl.uniform3f(waterP.u.uBoat, boat.x, boat.bob, boat.z); | |
| gl.uniform3f(waterP.u.uFwd, fwd[0], fwd[1], fwd[2]); | |
| gl.uniform1f(waterP.u.uPopAge, t - lastPopT); | |
| gl.uniform3f(waterP.u.uPopPos, popPos[0], popPos[1], popPos[2]); | |
| gl.uniform3f(waterP.u.uCam, eye[0], eye[1], eye[2]); | |
| gl.bindBuffer(gl.ARRAY_BUFFER, gridBuf); setAttribs(waterP, [["aGrid",2,0]], 0); | |
| gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, gridIdx); | |
| gl.drawElements(gl.TRIANGLES, N*N*6, gl.UNSIGNED_SHORT, 0); | |
| buildBoat(boat.rudder, t, Math.abs(boat.throttle)); | |
| gl.useProgram(boatP.p); | |
| gl.uniformMatrix4fv(boatP.u.uProj, false, proj); | |
| gl.uniformMatrix4fv(boatP.u.uView, false, view.m); | |
| gl.uniformMatrix4fv(boatP.u.uModel, false, model); | |
| gl.uniform3f(boatP.u.uOrigin, origin[0], origin[1], origin[2]); | |
| gl.uniform3f(boatP.u.uCam, eye[0], eye[1], eye[2]); | |
| gl.bindBuffer(gl.ARRAY_BUFFER, boatBuf); | |
| gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(boatVerts), gl.DYNAMIC_DRAW); | |
| setAttribs(boatP, [["aPos",3,0],["aCol",3,12],["aNormal",3,24],["aGlow",1,36],["aMat",1,40]], 44); | |
| gl.drawArrays(gl.TRIANGLES, 0, boatVerts.length/11); | |
| // puffs | |
| gl.useProgram(puffP.p); | |
| gl.uniformMatrix4fv(puffP.u.uProj, false, proj); | |
| gl.uniformMatrix4fv(puffP.u.uView, false, view.m); | |
| const data = []; | |
| const R = view.right, U = view.up; | |
| for(const pf of puffs){ | |
| const s = pf.size; | |
| const corners = [[-1,-1],[1,-1],[1,1],[-1,-1],[1,1],[-1,1]]; | |
| for(const c of corners){ | |
| data.push( | |
| pf.p[0] + R[0]*c[0]*s + U[0]*c[1]*s, | |
| pf.p[1] + R[1]*c[0]*s + U[1]*c[1]*s, | |
| pf.p[2] + R[2]*c[0]*s + U[2]*c[1]*s, | |
| c[0], c[1], clamp(pf.life/0.05,0,1) * (1 - pf.life/pf.max), pf.kind, pf.seed); | |
| } | |
| } | |
| if(data.length){ | |
| gl.bindBuffer(gl.ARRAY_BUFFER, puffBuf); | |
| gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(data), gl.DYNAMIC_DRAW); | |
| setAttribs(puffP, [["aPos",3,0],["aUV",2,12],["aAlpha",1,20],["aKind",1,24],["aSeed",1,28]], 32); | |
| gl.depthMask(false); gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); | |
| gl.drawArrays(gl.TRIANGLES, 0, data.length/8); | |
| gl.disable(gl.BLEND); gl.depthMask(true); | |
| } | |
| /* --- update puffs --- */ | |
| for(const pf of puffs){ | |
| pf.life += dt; | |
| pf.size = Math.min(0.22, pf.size + dt*0.45); | |
| pf.p[0] += pf.v[0]*dt; pf.p[2] += pf.v[2]*dt; | |
| const decay = Math.exp(-dt*2.2); | |
| pf.v[0] *= decay; pf.v[2] *= decay; | |
| if(pf.kind === 1 && pf.life < pf.max*0.55 && landH(pf.p[0], pf.p[2]) <= 0.02){ | |
| const surf = waveH(pf.p[0], pf.p[2], t); | |
| // the mouth is submerged now, so a bubble has to rise to the surface before it reads as foam | |
| if(pf.p[1] >= surf - 0.02) pf.p[1] = surf + 0.02; | |
| } else { | |
| pf.v[1] *= decay; // then it lifts off as steam | |
| pf.p[1] += pf.v[1]*dt; | |
| } | |
| } | |
| puffs = puffs.filter(p=>p.life < p.max); | |
| /* --- engine hum / water hiss levels --- */ | |
| if(AC){ | |
| const lvl = Math.abs(boat.throttle); | |
| // the candle is always heating something, so at rest there is a soft idle chug rather than silence | |
| if(hum1){ | |
| hum1.frequency.setTargetAtTime(46 + 22*lvl, AC.currentTime, 0.2); | |
| hum2.frequency.setTargetAtTime(71 + 34*lvl, AC.currentTime, 0.2); | |
| } | |
| engineGain.gain.value += ((0.012 + 0.030*lvl) - engineGain.gain.value) * (1 - Math.exp(-dt*4)); | |
| // the water hiss only exists while there is water to hiss against | |
| hissGain.gain.value += (((0.005 + 0.014*Math.min(1, Math.abs(boat.speed)/4)) * wetted) - hissGain.gain.value) * (1 - Math.exp(-dt*4)); | |
| if(scrapeGain){ | |
| const s = (boat.aground && Math.abs(boat.speed) > 0.15) ? 0.018 : 0.0; | |
| scrapeGain.gain.value += (s - scrapeGain.gain.value) * (1 - Math.exp(-dt*3)); | |
| } | |
| } | |
| requestAnimationFrame(frame); | |
| } | |
| reset(); | |
| requestAnimationFrame(frame); | |
| </script> | |
| </body> | |
| </html> | |