One commited on
Commit
ac84827
Β·
verified Β·
1 Parent(s): 149084f

Upload index.html

Browse files
Files changed (1) hide show
  1. index.html +170 -68
index.html CHANGED
@@ -85,8 +85,9 @@ function genKoiPatches(type) {
85
  }
86
  let fishes = [], bubbles = [], seaweeds = [], motes = [], rays = [], rocks = [], grass = [], fgGrass = [];
87
  let curNight = 0; // v7: shared night factor (dims the top-light + sheen)
88
- let plantPoints = []; // collision points along every stalk/branch, rebuilt each frame
89
  let sandHeights = [];
 
90
 
91
  class Fish {
92
  constructor() {
@@ -865,6 +866,8 @@ function buildScene() {
865
  rays = [];
866
  rocks = [];
867
  sandHeights = [];
 
 
868
  grass = []; // v10.1: clear on rebuild so resize/F11 doesn't stack a second row
869
  fgGrass = []; // v12: same reset for the foreground layer (resize/F11 safety)
870
 
@@ -929,7 +932,9 @@ function buildScene() {
929
  off: new Float32Array(9), // lateral bend per segment (fish push leaves aside)
930
  offV: new Float32Array(9), // velocity of that bend
931
  branches: [],
932
- points: []
 
 
933
  };
934
  const nBr = 1 + ((Math.random() * 3) | 0); // 1..3 branches per stalk
935
  for (let k = 0; k < nBr; k++) {
@@ -971,6 +976,7 @@ function buildScene() {
971
  const baseHue = rand(95, 155);
972
  const blades = 2 + ((Math.random() * 4) | 0); // 2..5 blades per tuft
973
  for (let b = 0; b < blades; b++) {
 
974
  grass.push({
975
  x: gx + rand(-7, 7),
976
  y: gy,
@@ -985,11 +991,14 @@ function buildScene() {
985
  cA1: rand(-5, 5), // primary bend amplitude
986
  cF1: rand(2.2, 4.5), // frequency along blade length
987
  cP1: rand(0, Math.PI * 2), // phase offset
988
- segs: 3 + ((Math.random() * 2) | 0), // 3-4 segments for tapered outline
989
  // v17: secondary wobble - finer inner shimmer per blade
990
  sAmp: rand(1.5, 3.5),
991
  sFreq: rand(4.5, 8),
992
- sPh: rand(0, Math.PI * 2)
 
 
 
993
  });
994
  }
995
  }
@@ -1005,6 +1014,7 @@ function buildScene() {
1005
  const baseHue = rand(46, 76); // v12.1: yellowish-green β€” pops against the mid-ground green grass
1006
  const nBlades = 3 + ((Math.random() * 4) | 0); // 3..6 blades per clump
1007
  for (let b = 0; b < nBlades; b++) {
 
1008
  fgGrass.push({
1009
  x: cx + rand(-7.5, 7.5), // 50%: tighter clumps (was -15..15)
1010
  y: clusterBaseY + rand(-2, 4), // 50%: finer vertical jitter (was -4..8)
@@ -1018,15 +1028,23 @@ function buildScene() {
1018
  cA1: rand(-8, 8), // primary bend amplitude (larger for foreground)
1019
  cF1: rand(2, 4), // frequency along blade length
1020
  cP1: rand(0, Math.PI * 2), // phase offset
1021
- segs: 4 + ((Math.random() * 2) | 0), // 4-5 segments for tapered outline
1022
  // v17: secondary wobble - finer inner shimmer per blade (foreground)
1023
  sAmp: rand(2, 4.5),
1024
  sFreq: rand(3.8, 6.5),
1025
- sPh: rand(0, Math.PI * 2)
 
 
1026
  });
1027
  }
1028
  }
1029
 
 
 
 
 
 
 
1030
  const nFish = Math.max(7, Math.floor(W / 130));
1031
  for (let i = 0; i < nFish; i++) fishes.push(new Fish());
1032
 
@@ -1038,9 +1056,9 @@ function buildScene() {
1038
  }
1039
 
1040
  for (let i = 0; i < 5; i++) {
1041
- // random blur state: ~3px (semi-blurry), ~7px (blurry), or ~14px (very blurry) for visual variety
1042
  const blurRoll = Math.random();
1043
- const blur = blurRoll < 0.34 ? 3 : (blurRoll < 0.67 ? 7 : 14);
1044
  rays.push({
1045
  x: rand(0, W), w: rand(80, 210), tilt: rand(-0.35, 0.35),
1046
  speed: rand(0.06, 0.14) * (Math.random() < 0.5 ? 1 : -1), alpha: rand(0.04, 0.09), blur,
@@ -1140,73 +1158,145 @@ function drawBackground(light) {
1140
  ctx.fillRect(0, 0, W, H);
1141
  }
1142
 
1143
- // PERF: shared scratch arrays for drawRays. The beam spines used to allocate fresh
1144
- // point arrays β€” plus ~72 tiny [x,y] pairs per ray, per frame. Rendering is sequential,
1145
- // so one set of preallocated slots serves every ray with zero per-frame allocation.
1146
- const RAY_SEGS = 18; // vertical resolution of each beam's undulating spine
1147
- const _rayL = [], _rayR = [], _rayLC = [], _rayRC = [];
1148
- for (let i = 0; i <= RAY_SEGS; i++) {
1149
- _rayL.push([0, 0]); _rayR.push([0, 0]); _rayLC.push([0, 0]); _rayRC.push([0, 0]);
1150
- }
1151
-
1152
- function drawRays(t, light) {
1153
- const [rr, rg, rb] = light.ray;
1154
- const segs = RAY_SEGS;
 
 
 
 
 
 
 
1155
  for (const r of rays) {
1156
- // apply per-ray blur state: 0px (as-is), 3px (semi-blurry), or 7px (blurry)
1157
- ctx.filter = r.blur > 0 ? `blur(${r.blur}px)` : 'none';
1158
- // slow horizontal drift of the whole beam (kept from v1)
1159
- const off = Math.sin(t * r.speed * 6 + r.x) * 55;
1160
- const x0 = r.x + off;
1161
- const topW = r.w * 0.45, botW = r.w * 1.9;
1162
  const shift = r.tilt * H;
 
1163
 
1164
- // Undulating spine: the beam's centerline bends left/right as it descends,
1165
- // driven by two traveling sine waves (different wavelength & speed) so it
1166
- // shimmers like sunlight refracted through moving water, seen from inside.
1167
- const L = _rayL, R = _rayR;
1168
- for (let i = 0; i <= segs; i++) {
1169
- const v = i / segs; // 0 at the surface, 1 near the floor
1170
- const y = -10 + v * (H * 0.95 + 10);
1171
- const grow = 0.35 + 0.65 * v; // straighter up top, bends more with depth
1172
- const wave =
1173
- Math.sin(v * r.uLen + t * r.uSpd + r.uPh) * r.uAmp +
1174
- Math.sin(v * r.vLen - t * r.vSpd + r.vPh) * r.vAmp;
1175
- const cx = x0 + shift * v + wave * grow; // undulating center x at this depth
1176
- const halfW = topW + (botW - topW) * v; // beam widens as it falls
1177
- L[i][0] = cx - halfW; L[i][1] = y;
1178
- R[i][0] = cx + halfW; R[i][1] = y;
1179
  }
1180
-
1181
- // Fill a band between two edge polylines with a vertical fade (matches v1 look).
1182
- const band = (a, b, aTop, midStop, midFactor) => {
1183
- ctx.beginPath();
1184
- ctx.moveTo(a[0][0], a[0][1]);
1185
- for (let i = 1; i < a.length; i++) ctx.lineTo(a[i][0], a[i][1]);
1186
- for (let i = b.length - 1; i >= 0; i--) ctx.lineTo(b[i][0], b[i][1]);
1187
- ctx.closePath();
1188
- const g = ctx.createLinearGradient(0, 0, 0, H);
1189
- g.addColorStop(0, `rgba(${rr},${rg},${rb},${aTop})`);
1190
- g.addColorStop(midStop, `rgba(${rr},${rg},${rb},${aTop * midFactor})`);
1191
- g.addColorStop(1, `rgba(${rr},${rg},${rb},0)`);
1192
- ctx.fillStyle = g;
1193
- ctx.fill();
 
 
 
 
 
 
 
 
1194
  };
1195
 
1196
- // soft outer beam (v1 alphas), now following the undulating spine
1197
- band(L, R, r.alpha, 0.55, 0.45);
1198
-
1199
- // brighter, narrower inner core riding the same undulation (v1 core profile)
1200
- const LC = _rayLC, RC = _rayRC;
1201
- for (let i = 0; i <= segs; i++) {
1202
- const [lx, ly] = L[i], [rx2, ry2] = R[i];
 
 
 
 
 
 
 
 
1203
  const cxm = (lx + rx2) / 2;
1204
  const coreHalf = ((rx2 - lx) / 2) * 0.42; // 42% of the local half-width, as in v1
1205
- LC[i][0] = cxm - coreHalf; LC[i][1] = ly;
1206
- RC[i][0] = cxm + coreHalf; RC[i][1] = ry2;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1207
  }
1208
- band(LC, RC, r.alpha * 1.5, 0.65, 0);
1209
- ctx.filter = 'none'; // reset blur before next ray or other drawing
1210
  }
1211
  }
1212
 
@@ -1605,10 +1695,17 @@ function buildGrainTile() {
1605
  g.putImageData(img, 0, 0);
1606
  grainPattern = ctx.createPattern(tile, "repeat"); // cached once, reused every frame
1607
  }
 
1608
  function drawGrain() {
1609
  if (!grainPattern) return;
1610
- const ox = (Math.random() * GRAIN_TILE) | 0; // random offset each frame -> animated grain
1611
- const oy = (Math.random() * GRAIN_TILE) | 0;
 
 
 
 
 
 
1612
  ctx.save();
1613
  ctx.globalCompositeOperation = "overlay";
1614
  ctx.globalAlpha = 0.7; // mild: subtle shimmer, not heavy noise
@@ -1621,6 +1718,10 @@ function drawGrain() {
1621
  // Precomputed static layers β€” the sand and rocks never move, so they are
1622
  // rendered once to an offscreen canvas and blitted each frame (v6) instead of
1623
  // re-drawing their gradients and paths every single frame.
 
 
 
 
1624
  let staticLayer = null;
1625
  function buildStaticLayer() {
1626
  staticLayer = document.createElement("canvas");
@@ -1711,6 +1812,7 @@ function frame(now) {
1711
 
1712
  function doResize() {
1713
  resize();
 
1714
  buildStaticLayer();
1715
  if (!grainPattern) buildGrainTile(); // v8: grain tile is size-independent, build once
1716
  }
 
85
  }
86
  let fishes = [], bubbles = [], seaweeds = [], motes = [], rays = [], rocks = [], grass = [], fgGrass = [];
87
  let curNight = 0; // v7: shared night factor (dims the top-light + sheen)
88
+ let plantPoints = [], _ppUsed = 0; // collision points β€” a pooled set, reused in place
89
  let sandHeights = [];
90
+ let sandParticles = []; // v22: grains of sand kicked up when a fast fish skims the sea floor
91
 
92
  class Fish {
93
  constructor() {
 
866
  rays = [];
867
  rocks = [];
868
  sandHeights = [];
869
+ sandParticles = []; // v22: fresh bed, no leftover grains
870
+ raySpritesBuiltFor = null; // v23: beam sprites are sized to the old canvas β€” rebuild them
871
  grass = []; // v10.1: clear on rebuild so resize/F11 doesn't stack a second row
872
  fgGrass = []; // v12: same reset for the foreground layer (resize/F11 safety)
873
 
 
932
  off: new Float32Array(9), // lateral bend per segment (fish push leaves aside)
933
  offV: new Float32Array(9), // velocity of that bend
934
  branches: [],
935
+ // PERF: stalk points preallocated once β€” updatePlants reuses these objects
936
+ // in place instead of pushing fresh {x,y} pairs every frame (GC churn)
937
+ points: Array.from({ length: 9 }, () => ({ x: 0, y: 0 })) // segs+1; can't read st inside its own initializer
938
  };
939
  const nBr = 1 + ((Math.random() * 3) | 0); // 1..3 branches per stalk
940
  for (let k = 0; k < nBr; k++) {
 
976
  const baseHue = rand(95, 155);
977
  const blades = 2 + ((Math.random() * 4) | 0); // 2..5 blades per tuft
978
  for (let b = 0; b < blades; b++) {
979
+ const bladeSegs = 3 + ((Math.random() * 2) | 0); // 3-4 segments for tapered outline
980
  grass.push({
981
  x: gx + rand(-7, 7),
982
  y: gy,
 
991
  cA1: rand(-5, 5), // primary bend amplitude
992
  cF1: rand(2.2, 4.5), // frequency along blade length
993
  cP1: rand(0, Math.PI * 2), // phase offset
994
+ segs: bladeSegs, // 3-4 segments for tapered outline
995
  // v17: secondary wobble - finer inner shimmer per blade
996
  sAmp: rand(1.5, 3.5),
997
  sFreq: rand(4.5, 8),
998
+ sPh: rand(0, Math.PI * 2),
999
+ // PERF: per-blade scratch points β€” drawGrass reuses them instead of
1000
+ // allocating a fresh pts array + point objects every frame (GC churn)
1001
+ pts: Array.from({ length: bladeSegs + 1 }, () => ({ x: 0, y: 0 }))
1002
  });
1003
  }
1004
  }
 
1014
  const baseHue = rand(46, 76); // v12.1: yellowish-green β€” pops against the mid-ground green grass
1015
  const nBlades = 3 + ((Math.random() * 4) | 0); // 3..6 blades per clump
1016
  for (let b = 0; b < nBlades; b++) {
1017
+ const fgSegs = 4 + ((Math.random() * 2) | 0); // 4-5 segments for tapered outline
1018
  fgGrass.push({
1019
  x: cx + rand(-7.5, 7.5), // 50%: tighter clumps (was -15..15)
1020
  y: clusterBaseY + rand(-2, 4), // 50%: finer vertical jitter (was -4..8)
 
1028
  cA1: rand(-8, 8), // primary bend amplitude (larger for foreground)
1029
  cF1: rand(2, 4), // frequency along blade length
1030
  cP1: rand(0, Math.PI * 2), // phase offset
1031
+ segs: fgSegs, // 4-5 segments for tapered outline
1032
  // v17: secondary wobble - finer inner shimmer per blade (foreground)
1033
  sAmp: rand(2, 4.5),
1034
  sFreq: rand(3.8, 6.5),
1035
+ sPh: rand(0, Math.PI * 2),
1036
+ // PERF: per-blade scratch points β€” drawFgGrass reuses them in place
1037
+ pts: Array.from({ length: fgSegs + 1 }, () => ({ x: 0, y: 0 }))
1038
  });
1039
  }
1040
  }
1041
 
1042
+ // PERF: pool the plantPoints collision objects too β€” updatePlants reuses them
1043
+ // in place instead of allocating one per stalk/branch point every frame.
1044
+ let pcap = 8;
1045
+ for (const w of seaweeds) pcap += (w.segs + 1) + w.branches.length * 2;
1046
+ while (plantPoints.length < pcap) plantPoints.push({ x: 0, y: 0, r: 0, s: null });
1047
+
1048
  const nFish = Math.max(7, Math.floor(W / 130));
1049
  for (let i = 0; i < nFish; i++) fishes.push(new Fish());
1050
 
 
1056
  }
1057
 
1058
  for (let i = 0; i < 5; i++) {
1059
+ // random blur state: ~3.75px (semi-blurry), ~8.75px (blurry), or ~17.5px (very blurry) for visual variety
1060
  const blurRoll = Math.random();
1061
+ const blur = blurRoll < 0.34 ? 3.75 : (blurRoll < 0.67 ? 8.75 : 17.5);
1062
  rays.push({
1063
  x: rand(0, W), w: rand(80, 210), tilt: rand(-0.35, 0.35),
1064
  speed: rand(0.06, 0.14) * (Math.random() < 0.5 ? 1 : -1), alpha: rand(0.04, 0.09), blur,
 
1158
  ctx.fillRect(0, 0, W, H);
1159
  }
1160
 
1161
+ // ============ Light rays β€” pre-blurred sprites (v23 fast path) ============
1162
+ // The old version applied a canvas blur() filter to every beam on every frame;
1163
+ // canvas filters force slow CPU rasterization over large regions, which tanked FPS.
1164
+ // Now each beam's shape is rendered ONCE into its own offscreen sprite (blur + the
1165
+ // vertical fade baked in at build time), and per frame we just blit that sprite in
1166
+ // horizontal slices with the same undulation offsets as before β€” wobble + soft
1167
+ // edges, zero filters at runtime.
1168
+ const RAY_SEGS = 18; // vertical resolution of each beam's spine (at build time)
1169
+ const RAY_SLICES = 24; // horizontal slices per beam used for the wobble (per frame)
1170
+ const RAY_SPRITE_SCALE = 0.5; // sprites stored at half resolution β†’ softer + cheaper
1171
+ const RAY_PAD = 60; // room for the blur to spill past the beam edge
1172
+ let raySpritesBuiltFor = null;
1173
+ let tintCanvas = null, tctx = null;
1174
+
1175
+ function buildRaySprites() {
1176
+ const S = RAY_SPRITE_SCALE;
1177
+ const yTop0 = -10, ySpan = H * 0.95 + 10; // beam vertical extent (same as before)
1178
+
1179
+ let maxTw = 0, maxTh = 0;
1180
  for (const r of rays) {
 
 
 
 
 
 
1181
  const shift = r.tilt * H;
1182
+ const topW = r.w * 0.45, botW = r.w * 1.9;
1183
 
1184
+ // static spine in local coords (x relative to the beam origin); the per-frame
1185
+ // drift + wobble are added when slicing, so each sprite stays reusable
1186
+ const L = [], Rr = [];
1187
+ for (let i = 0; i <= RAY_SEGS; i++) {
1188
+ const v = i / RAY_SEGS;
1189
+ const y = yTop0 + v * ySpan;
1190
+ const cx = shift * v; // centerline x at this depth
1191
+ const halfW = topW + (botW - topW) * v; // beam widens as it falls
1192
+ L.push([cx - halfW, y]);
1193
+ Rr.push([cx + halfW, y]);
 
 
 
 
 
1194
  }
1195
+ let xMinL = Infinity, xMaxL = -Infinity;
1196
+ for (const [px] of L) if (px < xMinL) xMinL = px;
1197
+ for (const [px] of Rr) if (px > xMaxL) xMaxL = px;
1198
+
1199
+ const bw = Math.ceil(xMaxL - xMinL + RAY_PAD * 2);
1200
+ const bh = Math.ceil(ySpan + RAY_PAD * 2);
1201
+
1202
+ // draw the beam into a full-res scratch canvas (identity transform so the blur
1203
+ // radius is unambiguous), then downscale it into the half-res sprite
1204
+ const full = document.createElement("canvas");
1205
+ full.width = bw; full.height = bh;
1206
+ const fc = full.getContext("2d");
1207
+ fc.translate(-xMinL + RAY_PAD, -yTop0 + RAY_PAD); // local beam coords β†’ scratch px
1208
+ if (r.blur > 0) { try { fc.filter = `blur(${r.blur}px)`; } catch (e) {} }
1209
+
1210
+ const poly = (a, b) => {
1211
+ fc.beginPath();
1212
+ fc.moveTo(a[0][0], a[0][1]);
1213
+ for (let i = 1; i < a.length; i++) fc.lineTo(a[i][0], a[i][1]);
1214
+ for (let i = b.length - 1; i >= 0; i--) fc.lineTo(b[i][0], b[i][1]);
1215
+ fc.closePath();
1216
+ fc.fill();
1217
  };
1218
 
1219
+ // vertical fade baked into the sprite: bright at the surface, gone by the floor
1220
+ // (same gradient stops as before, mapped into scratch-canvas coordinates)
1221
+ const yS0 = -yTop0 + RAY_PAD; // scratch y of world y=0
1222
+ const yS1 = H - yTop0 + RAY_PAD; // scratch y of world y=H
1223
+ const gOuter = fc.createLinearGradient(0, yS0, 0, yS1);
1224
+ gOuter.addColorStop(0, `rgba(255,255,255,${r.alpha})`);
1225
+ gOuter.addColorStop(0.55, `rgba(255,255,255,${r.alpha * 0.45})`);
1226
+ gOuter.addColorStop(1, "rgba(255,255,255,0)");
1227
+ fc.fillStyle = gOuter;
1228
+ poly(L, Rr);
1229
+
1230
+ // brighter, narrower inner core riding the same spine (v1 core profile)
1231
+ const LC = [], RC = [];
1232
+ for (let i = 0; i <= RAY_SEGS; i++) {
1233
+ const [lx, ly] = L[i], [rx2, ry2] = Rr[i];
1234
  const cxm = (lx + rx2) / 2;
1235
  const coreHalf = ((rx2 - lx) / 2) * 0.42; // 42% of the local half-width, as in v1
1236
+ LC.push([cxm - coreHalf, ly]);
1237
+ RC.push([cxm + coreHalf, ry2]);
1238
+ }
1239
+ const gCore = fc.createLinearGradient(0, yS0, 0, yS1);
1240
+ gCore.addColorStop(0, `rgba(255,255,255,${r.alpha * 1.5})`);
1241
+ gCore.addColorStop(0.65, "rgba(255,255,255,0)");
1242
+ fc.fillStyle = gCore;
1243
+ poly(LC, RC);
1244
+
1245
+ const spr = document.createElement("canvas");
1246
+ spr.width = Math.max(1, Math.ceil(bw * S));
1247
+ spr.height = Math.max(1, Math.ceil(bh * S));
1248
+ const sc = spr.getContext("2d");
1249
+ sc.drawImage(full, 0, 0, bw, bh, 0, 0, spr.width, spr.height);
1250
+
1251
+ r.sprite = spr;
1252
+ r.tw = spr.width; r.th = spr.height; // sprite pixel size
1253
+ r.bw = bw; // beam width in CSS px (dest size)
1254
+ r.xMinL = xMinL; // leftmost local x of the beam body
1255
+ if (spr.width > maxTw) maxTw = spr.width;
1256
+ if (spr.height > maxTh) maxTh = spr.height;
1257
+ }
1258
+
1259
+ // shared tint canvas: per frame we recolor each white sprite here with the current
1260
+ // light.ray color, so beams still shift hue through day β†’ dusk β†’ night like before
1261
+ tintCanvas = document.createElement("canvas");
1262
+ tintCanvas.width = Math.max(1, maxTw);
1263
+ tintCanvas.height = Math.max(1, maxTh);
1264
+ tctx = tintCanvas.getContext("2d");
1265
+
1266
+ raySpritesBuiltFor = W + "x" + H;
1267
+ }
1268
+
1269
+ function drawRays(t, light) {
1270
+ if (!tintCanvas || raySpritesBuiltFor !== W + "x" + H) buildRaySprites();
1271
+ const [rr, rg, rb] = light.ray;
1272
+ const tintRGB = `rgb(${rr},${rg},${rb})`;
1273
+ const yTop0 = -10, ySpan = H * 0.95 + 10;
1274
+
1275
+ for (const r of rays) {
1276
+ // 1) recolor the white sprite into the shared temp canvas β€” one blit + one fill,
1277
+ // GPU-cheap, and it keeps the beams' day/dusk/night hue shifting like before
1278
+ tctx.clearRect(0, 0, r.tw, r.th);
1279
+ tctx.globalCompositeOperation = "source-over";
1280
+ tctx.drawImage(r.sprite, 0, 0);
1281
+ tctx.globalCompositeOperation = "source-in";
1282
+ tctx.fillStyle = tintRGB;
1283
+ tctx.fillRect(0, 0, r.tw, r.th);
1284
+
1285
+ // 2) blit horizontal slices with the undulation offset at each depth β€” same wave
1286
+ // math as before, so the beams shimmer exactly like they always have
1287
+ const off = Math.sin(t * r.speed * 6 + r.x) * 55; // slow drift of the whole beam
1288
+ for (let i = 0; i < RAY_SLICES; i++) {
1289
+ const vT = i / RAY_SLICES, vB = (i + 1) / RAY_SLICES, vM = (vT + vB) / 2;
1290
+ const ya = yTop0 + vT * ySpan, yb = yTop0 + vB * ySpan;
1291
+ const grow = 0.35 + 0.65 * vM; // straighter up top, bends more with depth
1292
+ const wave =
1293
+ Math.sin(vM * r.uLen + t * r.uSpd + r.uPh) * r.uAmp +
1294
+ Math.sin(vM * r.vLen - t * r.vSpd + r.vPh) * r.vAmp;
1295
+ const dx = r.x + off + wave * grow; // undulating center x at this depth
1296
+ ctx.drawImage(tintCanvas,
1297
+ 0, (ya + 10 + RAY_PAD) * RAY_SPRITE_SCALE, r.tw, (yb - ya) * RAY_SPRITE_SCALE,
1298
+ dx + r.xMinL - RAY_PAD, ya, r.bw, yb - ya);
1299
  }
 
 
1300
  }
1301
  }
1302
 
 
1695
  g.putImageData(img, 0, 0);
1696
  grainPattern = ctx.createPattern(tile, "repeat"); // cached once, reused every frame
1697
  }
1698
+ let grainOx = 0, grainOy = 0, lastGrainTime = 0;
1699
  function drawGrain() {
1700
  if (!grainPattern) return;
1701
+ // v17: half-speed grain β€” re-randomize the offset every other frame instead of every frame
1702
+ const now = performance.now();
1703
+ if (now - lastGrainTime >= 33) { // ~30fps grain updates instead of per-frame (~60fps)
1704
+ grainOx = (Math.random() * GRAIN_TILE) | 0;
1705
+ grainOy = (Math.random() * GRAIN_TILE) | 0;
1706
+ lastGrainTime = now;
1707
+ }
1708
+ const ox = grainOx, oy = grainOy;
1709
  ctx.save();
1710
  ctx.globalCompositeOperation = "overlay";
1711
  ctx.globalAlpha = 0.7; // mild: subtle shimmer, not heavy noise
 
1718
  // Precomputed static layers β€” the sand and rocks never move, so they are
1719
  // rendered once to an offscreen canvas and blitted each frame (v6) instead of
1720
  // re-drawing their gradients and paths every single frame.
1721
+ // PERF: half-resolution offscreen buffer for the light rays (see drawRays) β€” the
1722
+ // per-ray blur() filter is far cheaper rasterizing small areas than full-screen.
1723
+
1724
+
1725
  let staticLayer = null;
1726
  function buildStaticLayer() {
1727
  staticLayer = document.createElement("canvas");
 
1812
 
1813
  function doResize() {
1814
  resize();
1815
+ buildRaySprites(); // v23: rebuild the pre-blurred beam sprites for the new size
1816
  buildStaticLayer();
1817
  if (!grainPattern) buildGrainTile(); // v8: grain tile is size-independent, build once
1818
  }