One commited on
Commit
7d3efaf
·
verified ·
1 Parent(s): 720724f

Upload index.html

Browse files
Files changed (1) hide show
  1. index.html +105 -50
index.html CHANGED
@@ -105,8 +105,8 @@ function waveH(x, z, t){
105
  shader is handed the nearby ones as data, so the two can never disagree about whether land
106
  is there at all. The profile below is mirrored in the shader, so the boat sits on the same
107
  ground it is drawn on. */
108
- const ICELL = 48, MAX_ISLANDS = 25; // a 170 m grid spans at most 5 island cells each way
109
- const LAND = { beach: 0.35, shelf: 1.2, deep: 6.0 }; // beach: how far up the shore the boat may sit // beach: how far up the shore the boat may sit
110
  let SEED = 1337;
111
 
112
  function ihash(n){
@@ -131,37 +131,56 @@ function lnoise(x, z){
131
  return u + (v-u)*sz;
132
  }
133
 
134
- /* code packs the three shape values as three 4-bit fields, because that is the part both sides
135
- can decode identically: bits 0-3 peak, 4-7 profile, 8-11 roughness. Ranges come out as
136
- radius 14-30 m and peak 1.2-4.5 m above the sea. */
 
 
 
137
  function islandAt(cx, cz){
138
  const h = ihash((Math.imul(cx, 73856093) + Math.imul(cz, 19349153) + SEED) >>> 0);
139
- if((h & 255) >= 96) return null; // most of the world is open sea
 
 
 
 
140
  const s = ihash(h) & 0xFFFFFF;
 
 
141
  return {
142
  x: (cx + 0.5 + ((h >>> 8 & 255) - 127.5)/256) * ICELL,
143
  z: (cz + 0.5 + ((h >>> 16 & 255) - 127.5)/256) * ICELL,
144
- r: 14 + (s & 63) * 0.25,
145
- code: ((s >> 6) & 15) + ((s >> 10) & 15)*16 + ((s >> 14) & 15)*256
146
  };
147
  }
148
  function decode(code){
149
- return { peak: 1.2 + (code & 15)*0.22, shape: 1.2 + ((code >> 4) & 15)*0.03, rough: (code >> 8)/15 };
 
 
 
 
 
 
 
150
  }
151
 
152
  function landH(x, z){
153
  const cx = Math.floor(x/ICELL), cz = Math.floor(z/ICELL);
154
  // starting below sea level is what makes the depth readable: outside an island there is no
155
  // seabed, so it reads as deep water, and the shallow band around one is the shelf.
 
 
156
  let h = -LAND.deep;
157
- for(let j=-1;j<=1;j++) for(let i=-1;i<=1;i++){
 
158
  const isl = islandAt(cx+i, cz+j);
159
  if(!isl) continue;
160
  const d = Math.hypot(x - isl.x, z - isl.z);
161
  if(d > isl.r) continue;
162
  const p = decode(isl.code);
163
  const k = 1 - d/isl.r; // monotone: downhill always leads back to the sea
164
- const terr = Math.pow(k, p.shape) * (0.55 + 0.45*p.rough*lnoise(x, z));
 
165
  h = Math.max(h, p.peak*terr - LAND.shelf); // the shelf is the shallow ring around an island
166
  }
167
  return h;
@@ -170,11 +189,12 @@ function landGrad(x, z){
170
  const e = 0.30;
171
  return [(landH(x+e, z) - landH(x-e, z))/(2*e), (landH(x, z+e) - landH(x, z-e))/(2*e)];
172
  }
173
- // every island that can reach a vertex of the water grid
174
  function islandsNear(minX, maxX, minZ, maxZ){
175
  const out = [];
176
- for(let cz = Math.floor(minZ/ICELL); cz <= Math.floor(maxZ/ICELL) && out.length < MAX_ISLANDS; cz++)
177
- for(let cx = Math.floor(minX/ICELL); cx <= Math.floor(maxX/ICELL) && out.length < MAX_ISLANDS; cx++){
 
178
  const isl = islandAt(cx, cz);
179
  if(isl) out.push(isl);
180
  }
@@ -209,6 +229,7 @@ uint ihash(uint n){
209
  }
210
  float lhash(int x, int y){ return float(ihash(uint(x)*3419u + uint(y)*2147483647u) & 1023u) / 1023.0; }
211
  float lnoise(vec2 p){
 
212
  ivec2 i = ivec2(floor(p));
213
  vec2 f = p - vec2(float(i.x), float(i.y));
214
  f = f*f*(3.0-2.0*f);
@@ -222,11 +243,15 @@ float landH(vec2 p){
222
  float d = length(p - isl.xy);
223
  if(d > isl.z) continue;
224
  float code = isl.w;
225
- float peak = 1.2 + mod(code, 16.0) * 0.22;
226
- float shape = 1.2 + floor(mod(code/16.0, 16.0)) * 0.03;
227
- float rough = floor(code/256.0) / 15.0;
 
 
 
228
  float k = 1.0 - d/isl.z;
229
- float terr = pow(k, shape) * (0.55 + 0.45*rough*lnoise(p));
 
230
  h = max(h, peak*terr - 1.2);
231
  }
232
  return h;
@@ -388,7 +413,14 @@ out vec4 frag;
388
  ${NOISE_GLSL}
389
  void main(){
390
  vec3 n = normalize(vNormal);
391
- if(vLand > 0.0) n = normalize(cross(dFdy(vWorld), dFdx(vWorld))); // land reads as flat facets
 
 
 
 
 
 
 
392
  if(n.y < 0.0) n = -n;
393
  vec3 L = normalize(vec3(0.5, 0.75, -0.45));
394
  float ndl = max(dot(n, L), 0.0);
@@ -398,13 +430,14 @@ void main(){
398
  float tone = mix(floor(q)/4.0, q/4.0, 0.40);
399
 
400
  vec3 col;
401
- if(vLand > 0.0){
 
402
  float flatness = clamp(n.y, 0.0, 1.0); // "flat" is a reserved word in GLSL ES 3.0
403
- col = mix(uSand * 0.62, uSand, smoothstep(0.0, 0.30, vLand)); // wet sand at the waterline
404
- col = mix(col, uGrass, smoothstep(0.40, 1.10, vLand) * smoothstep(0.50, 0.75, flatness));
405
  col = mix(col, uRock, smoothstep(0.55, 0.80, 1.0 - flatness));
406
  col = col * (0.55 + 0.70 * tone);
407
- col = mix(col, col * 1.10, (vnoise(vWorld.xz * 2.2) - 0.5) * 0.35 * smoothstep(0.2, 1.0, vLand));
408
  } else {
409
  vec3 deep = vec3(0.04,0.17,0.38);
410
  vec3 mid = vec3(0.09,0.34,0.53);
@@ -436,8 +469,8 @@ void main(){
436
 
437
  // wispy foam, built the same way as the clouds, plus a band where shallow water meets the shore
438
  float wisps = smoothstep(0.42, 0.58, fbm(vWorld.xz * 1.2 + uTime * 0.02, 2));
439
- float shore = (vLand <= 0.0) ? smoothstep(0.50, 0.02, vDepth) * 0.22 : 0.0;
440
- float f = clamp(vFoam * (0.35 + 0.55 * wisps) + shore, 0.0, 1.0);
441
  col = mix(col, vec3(0.93,0.98,1.0), f);
442
 
443
  float fog = smoothstep(14.0, 70.0, length(vWorld - uCam));
@@ -713,6 +746,27 @@ function buildBoat(rudder, t, heat){
713
  };
714
  const rod=(a,b,th,col,glow=0,mat=0)=>tube(a,b,th,6,col,glow,mat,true);
715
  const limb=(a,b,r,col,mat=0)=>tube(a,b,r,6,col,0,mat,true);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
716
 
717
  // ---- hull ----
718
  // The profile is a function of position along the boat instead of a list of stations, so the
@@ -746,19 +800,26 @@ function buildBoat(rudder, t, heat){
746
  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);
747
 
748
  // ---- boiler, candle, exhaust ----
749
- const bz = 0.15;
750
- tube([0,0.22,bz],[0,0.42,bz], 0.12, 9, PAL.brass, 0, 1, true);
751
- cone(0, 0.42, bz, 0.12, 0.06, 9, PAL.brass, 0, 1);
752
- rod([0,0.215,-0.40],[0,0.215,0.05], 0.012, [0.30,0.28,0.24], 0, 1); // the rail the candle slides along
753
- const cz = -0.34 + 0.37*heat; // W/S pushes the candle towards the boiler
754
- const flame = 0.06 + 0.05*heat + 0.012*Math.sin(t*11.0) + 0.008*Math.sin(t*23.0);
755
- tube([0,0.22,cz],[0,0.34,cz], 0.04, 7, PAL.wax, 0, 0, true);
756
- cone(0, 0.34, cz, 0.04, flame, 7, PAL.flame, 1.0, 0);
757
  for(const side of [-1, 1]){
758
- tube([side*0.10,0.24,bz-0.10],[side*0.18,0.21,-1.35], 0.035, 7, PAL.metal, 0, 1, true);
759
- tube([side*0.18,0.21,-1.35],[side*0.18,-0.05,-1.58], 0.035, 7, PAL.metal, 0, 1, true);
 
 
 
760
  }
761
 
 
 
 
 
 
 
 
 
 
 
762
  // ---- tiller and rudder ----
763
  const ang = -rudder * 0.45;
764
  const head = [0, 0.16, -1.58];
@@ -782,19 +843,11 @@ function buildBoat(rudder, t, heat){
782
  sphere(PX, 0.70, -0.55, 0.095, PAL.skin, 0, 10, 6); // head
783
  disc(PX, 0.79, -0.55, 0.15, 10, PAL.hat); // brim
784
  cone(PX, 0.79, -0.55, 0.115, 0.10, 10, PAL.hat); // crown
785
- // two links with an elbow, solved for the handle - a straight rod from shoulder to tiller is
786
- // what read as an awkward arm before
787
- const shoulder = [PX, 0.54, -0.50], hand = [hx, hy, hz];
788
- const L1 = 0.24, L2 = 0.24;
789
- const d = sub(hand, shoulder), len = Math.max(1e-4, Math.hypot(d[0],d[1],d[2]));
790
- const u = scl(d, 1/len);
791
- const ca = Math.acos(clamp((L1*L1 + len*len - L2*L2)/(2*L1*len), -1, 1));
792
- let bend = sub([-0.55,-0.55,-0.1], scl(u, dot([-0.55,-0.55,-0.1], u)));
793
- if(Math.hypot(bend[0],bend[1],bend[2]) < 1e-4) bend = [0,-1,0]; else bend = norm(bend);
794
- const elbow = add(shoulder, add(scl(u, L1*Math.cos(ca)), scl(bend, L1*Math.sin(ca))));
795
- limb(shoulder, elbow, 0.05, PAL.jacket);
796
- limb(elbow, hand, 0.042, PAL.skin);
797
- sphere(hand[0], hand[1], hand[2], 0.045, PAL.skin, 0, 8, 4);
798
 
799
  // ---- pennant ----
800
  // the pole starts at the deck, not above it
@@ -981,7 +1034,7 @@ function reset(){
981
 
982
  function spawnPuff(model, origin, fwd, side, heat){
983
  if(puffs.length > 160) return;
984
- const lp = [side*0.18*BOAT_SCALE, -0.05*BOAT_SCALE, -1.58*BOAT_SCALE]; // the tube that just fired
985
  const wp = [
986
  origin[0] + model[0]*lp[0] + model[4]*lp[1] + model[8]*lp[2],
987
  origin[1] + model[1]*lp[0] + model[5]*lp[1] + model[9]*lp[2],
@@ -1196,7 +1249,9 @@ function frame(nowMs){
1196
  const decay = Math.exp(-dt*2.2);
1197
  pf.v[0] *= decay; pf.v[2] *= decay;
1198
  if(pf.kind === 1 && pf.life < pf.max*0.55 && landH(pf.p[0], pf.p[2]) <= 0.02){
1199
- pf.p[1] = waveH(pf.p[0], pf.p[2], t) + 0.02; // the jet is still at the surface, so it reads as foam
 
 
1200
  } else {
1201
  pf.v[1] *= decay; // then it lifts off as steam
1202
  pf.p[1] += pf.v[1]*dt;
 
105
  shader is handed the nearby ones as data, so the two can never disagree about whether land
106
  is there at all. The profile below is mirrored in the shader, so the boat sits on the same
107
  ground it is drawn on. */
108
+ const ICELL = 48, MAX_ISLANDS = 48, MAXR = 70; // a 170 m grid plus the reach of the largest island
109
+ const LAND = { beach: 0.35, shelf: 1.2, deep: 6.0 }; // beach: how far up the shore the boat may sit
110
  let SEED = 1337;
111
 
112
  function ihash(n){
 
131
  return u + (v-u)*sz;
132
  }
133
 
134
+ /* code packs four 4-bit fields, because that is the part both sides can decode identically:
135
+ bits 0-3 peak, 4-7 profile, 8-11 roughness, 12-15 type. Powers of two are exact in float32,
136
+ so the decode cannot drift between JS and the GPU.
137
+ type 0 is the common islet: radius 14-30 m, peak 1.2-4.5 m, about 36 cells in 256.
138
+ type 1 is a large flattish island: radius 50-70 m, peak 2-7.2 m, about 2 cells in 256.
139
+ type 2 is a tall narrow pinnacle: radius 9-18 m, peak 14-38 m, about 3 cells in 256. */
140
  function islandAt(cx, cz){
141
  const h = ihash((Math.imul(cx, 73856093) + Math.imul(cz, 19349153) + SEED) >>> 0);
142
+ const low = h & 255;
143
+ let type = 0;
144
+ if(low < 2) type = 1; // rare: a large flattish island
145
+ else if(low < 5) type = 2; // rarer: a tall narrow pinnacle
146
+ else if(low >= 96) return null; // most of the world is open sea
147
  const s = ihash(h) & 0xFFFFFF;
148
+ const rb = s & 63, pk = (s >> 6) & 15, sh = (s >> 10) & 15, rg = (s >> 14) & 15;
149
+ const r = type === 1 ? 50 + rb*0.31 : type === 2 ? 9 + rb*0.14 : 14 + rb*0.25;
150
  return {
151
  x: (cx + 0.5 + ((h >>> 8 & 255) - 127.5)/256) * ICELL,
152
  z: (cz + 0.5 + ((h >>> 16 & 255) - 127.5)/256) * ICELL,
153
+ r, code: pk + sh*16 + rg*256 + type*4096
 
154
  };
155
  }
156
  function decode(code){
157
+ const pk = code & 15, sh = (code >> 4) & 15, rg = (code >> 8) & 15, type = code >> 12;
158
+ let peak = 1.2 + pk*0.22, shape = 1.2 + sh*0.03, ramp = 0;
159
+ // shape is the exponent on the falloff, so a small shape is nearly flat: a pinnacle gets near
160
+ // vertical sides. A flat island instead uses a ramp - flat for most of the radius, then a
161
+ // straight fall over the last fraction of it, which is the beach the boat can actually sit on.
162
+ if(type === 1){ peak = 2.0 + pk*0.35; ramp = 0.10 + sh*0.04; }
163
+ else if(type === 2){ peak = 14.0 + pk*1.6; shape = 0.04 + sh*0.012; }
164
+ return { peak, shape, rough: rg/15, type, ramp };
165
  }
166
 
167
  function landH(x, z){
168
  const cx = Math.floor(x/ICELL), cz = Math.floor(z/ICELL);
169
  // starting below sea level is what makes the depth readable: outside an island there is no
170
  // seabed, so it reads as deep water, and the shallow band around one is the shelf.
171
+ // The search has to reach as far as the largest island, otherwise the boat could sit on water
172
+ // that the shader is drawing as land.
173
  let h = -LAND.deep;
174
+ const span = Math.ceil(MAXR/ICELL);
175
+ for(let j=-span;j<=span;j++) for(let i=-span;i<=span;i++){
176
  const isl = islandAt(cx+i, cz+j);
177
  if(!isl) continue;
178
  const d = Math.hypot(x - isl.x, z - isl.z);
179
  if(d > isl.r) continue;
180
  const p = decode(isl.code);
181
  const k = 1 - d/isl.r; // monotone: downhill always leads back to the sea
182
+ const nz = 0.55 + 0.45*p.rough*lnoise(x, z);
183
+ const terr = (p.ramp > 0 ? Math.min(1, k/p.ramp) : Math.pow(k, p.shape)) * nz;
184
  h = Math.max(h, p.peak*terr - LAND.shelf); // the shelf is the shallow ring around an island
185
  }
186
  return h;
 
189
  const e = 0.30;
190
  return [(landH(x+e, z) - landH(x-e, z))/(2*e), (landH(x, z+e) - landH(x, z-e))/(2*e)];
191
  }
192
+ // every island that can reach a vertex of the water grid, extended by the reach of the largest one
193
  function islandsNear(minX, maxX, minZ, maxZ){
194
  const out = [];
195
+ const a = minX - MAXR, b = maxX + MAXR, c = minZ - MAXR, d = maxZ + MAXR;
196
+ for(let cz = Math.floor(c/ICELL); cz <= Math.floor(d/ICELL) && out.length < MAX_ISLANDS; cz++)
197
+ for(let cx = Math.floor(a/ICELL); cx <= Math.floor(b/ICELL) && out.length < MAX_ISLANDS; cx++){
198
  const isl = islandAt(cx, cz);
199
  if(isl) out.push(isl);
200
  }
 
229
  }
230
  float lhash(int x, int y){ return float(ihash(uint(x)*3419u + uint(y)*2147483647u) & 1023u) / 1023.0; }
231
  float lnoise(vec2 p){
232
+ p = p * 0.08; // same lattice scale as the JS copy
233
  ivec2 i = ivec2(floor(p));
234
  vec2 f = p - vec2(float(i.x), float(i.y));
235
  f = f*f*(3.0-2.0*f);
 
243
  float d = length(p - isl.xy);
244
  if(d > isl.z) continue;
245
  float code = isl.w;
246
+ float pk = mod(code, 16.0), sh = floor(mod(code/16.0, 16.0)), rg = floor(mod(code/256.0, 16.0));
247
+ float type = floor(code/4096.0);
248
+ float peak = 1.2 + pk*0.22, shape = 1.2 + sh*0.03;
249
+ if(type == 1.0){ peak = 2.0 + pk*0.35; }
250
+ else if(type == 2.0){ peak = 14.0 + pk*1.6; shape = 0.04 + sh*0.012; }
251
+ float rough = rg/15.0;
252
  float k = 1.0 - d/isl.z;
253
+ float nz = 0.55 + 0.45*rough*lnoise(p);
254
+ float terr = (type == 1.0) ? min(1.0, k/(0.10 + sh*0.04)) * nz : pow(k, shape) * nz;
255
  h = max(h, peak*terr - 1.2);
256
  }
257
  return h;
 
413
  ${NOISE_GLSL}
414
  void main(){
415
  vec3 n = normalize(vNormal);
416
+ vec3 fn = normalize(cross(dFdy(vWorld), dFdx(vWorld))); // the facet actually drawn
417
+ if(fn.y < 0.0) fn = -fn;
418
+ // A facet this steep can only be a cliff face - the sea surface never gets that steep. The land
419
+ // or water decision is made per fragment from interpolated values, so a triangle that straddles
420
+ // the shoreline is split diagonally between the two materials, and on a tall island that split
421
+ // runs most of the way up the cliff. Deciding from the facet keeps a cliff one material.
422
+ bool isLand = vLand > 0.0 || fn.y < 0.12;
423
+ if(isLand) n = fn;
424
  if(n.y < 0.0) n = -n;
425
  vec3 L = normalize(vec3(0.5, 0.75, -0.45));
426
  float ndl = max(dot(n, L), 0.0);
 
430
  float tone = mix(floor(q)/4.0, q/4.0, 0.40);
431
 
432
  vec3 col;
433
+ float lv = max(vLand, 0.0);
434
+ if(isLand){
435
  float flatness = clamp(n.y, 0.0, 1.0); // "flat" is a reserved word in GLSL ES 3.0
436
+ col = mix(uSand * 0.62, uSand, smoothstep(0.0, 0.30, lv)); // wet sand at the waterline
437
+ col = mix(col, uGrass, smoothstep(0.40, 1.10, lv) * smoothstep(0.50, 0.75, flatness));
438
  col = mix(col, uRock, smoothstep(0.55, 0.80, 1.0 - flatness));
439
  col = col * (0.55 + 0.70 * tone);
440
+ col = mix(col, col * 1.10, (vnoise(vWorld.xz * 2.2) - 0.5) * 0.35 * smoothstep(0.2, 1.0, lv));
441
  } else {
442
  vec3 deep = vec3(0.04,0.17,0.38);
443
  vec3 mid = vec3(0.09,0.34,0.53);
 
469
 
470
  // wispy foam, built the same way as the clouds, plus a band where shallow water meets the shore
471
  float wisps = smoothstep(0.42, 0.58, fbm(vWorld.xz * 1.2 + uTime * 0.02, 2));
472
+ float shore = isLand ? 0.0 : smoothstep(0.50, 0.02, max(vDepth, 0.0)) * 0.22;
473
+ float f = clamp((isLand ? 0.0 : vFoam) * (0.35 + 0.55 * wisps) + shore, 0.0, 1.0);
474
  col = mix(col, vec3(0.93,0.98,1.0), f);
475
 
476
  float fog = smoothstep(14.0, 70.0, length(vWorld - uCam));
 
746
  };
747
  const rod=(a,b,th,col,glow=0,mat=0)=>tube(a,b,th,6,col,glow,mat,true);
748
  const limb=(a,b,r,col,mat=0)=>tube(a,b,r,6,col,0,mat,true);
749
+ const ellipsoid=(cx,cy,cz,rx,ry,rz,col,mat=0,seg=10,rows=5)=>{
750
+ 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];
751
+ for(let j=0;j<rows;j++) for(let i=0;i<seg;i++){
752
+ 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;
753
+ quad(P(a0,b0),P(a0,b1),P(a1,b1),P(a1,b0),col,0,mat,true);
754
+ }
755
+ };
756
+ 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); };
757
+ // a two link arm solved for where the hand has to be, so the elbow bends as the thing being
758
+ // held moves. Straight rods from shoulder to handle were what read as an awkward arm.
759
+ const arm=(sh, target, L1, L2, bendDir)=>{
760
+ const d = sub(target, sh), len = Math.max(1e-4, Math.hypot(d[0],d[1],d[2]));
761
+ const u = scl(d, 1/len);
762
+ const ca = Math.acos(clamp((L1*L1 + len*len - L2*L2)/(2*L1*len), -1, 1));
763
+ let b = sub(bendDir, scl(u, dot(bendDir, u)));
764
+ if(Math.hypot(b[0],b[1],b[2]) < 1e-4) b = [0,-1,0]; else b = norm(b);
765
+ const elbow = add(sh, add(scl(u, L1*Math.cos(ca)), scl(b, L1*Math.sin(ca))));
766
+ limb(sh, elbow, 0.05, PAL.jacket);
767
+ limb(elbow, target, 0.042, PAL.skin);
768
+ sphere(target[0], target[1], target[2], 0.045, PAL.skin, 0, 8, 4);
769
+ };
770
 
771
  // ---- hull ----
772
  // The profile is a function of position along the boat instead of a list of stations, so the
 
800
  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);
801
 
802
  // ---- boiler, candle, exhaust ----
803
+ const bz = 0.08, by = 0.36;
804
+ ellipsoid(0, by, bz, 0.16, 0.05, 0.16, PAL.brass, 1, 10, 4);
 
 
 
 
 
 
805
  for(const side of [-1, 1]){
806
+ // the pipes are what hold the boiler up: they come out of its underside, dip below the
807
+ // waterline just aft of it - that dip is the U bend the water sits in - and the mouth
808
+ // stays submerged at the back.
809
+ poly([[side*0.12, by-0.01, bz-0.02], [side*0.15, 0.18, -0.08], [side*0.17, -0.02, -0.45],
810
+ [side*0.17, -0.06, -1.25], [side*0.17, -0.14, -1.58]], 0.035, PAL.metal, 0, 1);
811
  }
812
 
813
+ // the candle stands on the deck under the boiler and slides forward towards it, so at full
814
+ // throttle the flame is just under the boiler's underside, where the heat actually does something
815
+ const cz = -0.32 + 0.30*heat;
816
+ const cy = dAt(cz); // on the deck, not floating above it
817
+ const flame = 0.03 + 0.02*heat + 0.008*Math.sin(t*11.0) + 0.005*Math.sin(t*23.0);
818
+ tube([0, cy, cz], [0, cy+0.035, cz], 0.035, 7, PAL.wax, 0, 0, true);
819
+ cone(0, cy+0.035, cz, 0.035, flame, 7, PAL.flame, 1.0, 0);
820
+ const knob = [-0.05, cy+0.022, cz]; // the little handle on the candle's side
821
+ sphere(knob[0], knob[1], knob[2], 0.018, [0.30,0.28,0.24], 0, 6, 3);
822
+
823
  // ---- tiller and rudder ----
824
  const ang = -rudder * 0.45;
825
  const head = [0, 0.16, -1.58];
 
843
  sphere(PX, 0.70, -0.55, 0.095, PAL.skin, 0, 10, 6); // head
844
  disc(PX, 0.79, -0.55, 0.15, 10, PAL.hat); // brim
845
  cone(PX, 0.79, -0.55, 0.115, 0.10, 10, PAL.hat); // crown
846
+ // right hand on the tiller, left hand on the candle handle. Both are solved for wherever the
847
+ // thing being held currently is, so pushing the throttle straightens the left arm as the candle
848
+ // moves forward, and steering swings the right one.
849
+ arm([PX+0.06, 0.52, -0.48], [hx, hy, hz], 0.29, 0.29, [-0.55,-0.55,-0.1]);
850
+ arm([PX-0.06, 0.52, -0.44], knob, 0.29, 0.29, [-0.5,-0.6,0.05]);
 
 
 
 
 
 
 
 
851
 
852
  // ---- pennant ----
853
  // the pole starts at the deck, not above it
 
1034
 
1035
  function spawnPuff(model, origin, fwd, side, heat){
1036
  if(puffs.length > 160) return;
1037
+ const lp = [side*0.17*BOAT_SCALE, -0.14*BOAT_SCALE, -1.58*BOAT_SCALE]; // the submerged tube mouth
1038
  const wp = [
1039
  origin[0] + model[0]*lp[0] + model[4]*lp[1] + model[8]*lp[2],
1040
  origin[1] + model[1]*lp[0] + model[5]*lp[1] + model[9]*lp[2],
 
1249
  const decay = Math.exp(-dt*2.2);
1250
  pf.v[0] *= decay; pf.v[2] *= decay;
1251
  if(pf.kind === 1 && pf.life < pf.max*0.55 && landH(pf.p[0], pf.p[2]) <= 0.02){
1252
+ const surf = waveH(pf.p[0], pf.p[2], t);
1253
+ // the mouth is submerged now, so a bubble has to rise to the surface before it reads as foam
1254
+ if(pf.p[1] >= surf - 0.02) pf.p[1] = surf + 0.02;
1255
  } else {
1256
  pf.v[1] *= decay; // then it lifts off as steam
1257
  pf.p[1] += pf.v[1]*dt;