פני מים

רקע שמזמין נגיעה. האדוות הן תגובה ישירה למשתמש, ולכן הן פועלות גם כשהתנועה האוטומטית כבויה. זו בדיוק ההבחנה שגורמת לאתר להרגיש קשוב ולא רועש. פני מים הוא רכיב מתוך רכיבים של כליקס. מה שמורידים הוא רכיב שלם, גם כ-HTML עצמאי וגם כרכיב React. אין ספרייה חיצונית, אין שלב בנייה, ואם כליקס ייעלם מחר הקוד ימשיך לעבוד. מתאים לעמודים ולמסמכים שעוסקים בWebGL, בלי ספריות ורקע. באותה סדרה יש גם שאלות נפוצות, ציר תהליך וקרוסלת המלצות.

רכיב · WebGL · בלי ספריות · רקע

<!-- פני מים: הקנבס נמתח על ההורה, שצריך להיות position:relative -->
<canvas id="klix-water" aria-hidden="true"></canvas>

<style>
  /* הגרדיאנט הזה הוא מה שנשאר אם אין WebGL2 */
  #klix-water{position:absolute;inset:0;width:100%;height:100%;
    background:linear-gradient(180deg,#0b2540,#04101f)}
</style>

<script>
(function () {
  var canvas = document.getElementById('klix-water');
  if (!canvas) return;

  var FRAGMENT = "#version 300 es\nprecision highp float;\nout vec4 outColor;\nuniform vec2 u_res;\nuniform float u_time;\nuniform vec2 u_mouse;\n\nfloat hash(vec2 p){ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }\n\nfloat noise(vec2 p){\n  vec2 i = floor(p); vec2 f = fract(p);\n  float a = hash(i), b = hash(i + vec2(1.0, 0.0));\n  float c = hash(i + vec2(0.0, 1.0)), d = hash(i + vec2(1.0, 1.0));\n  vec2 u = f * f * (3.0 - 2.0 * f);\n  return mix(a, b, u.x) + (c - a) * u.y * (1.0 - u.x) + (d - b) * u.x * u.y;\n}\n\n// ארבע אוקטבות ולא שש. ההבדל הוויזואלי ברקע מטושטש זניח,\n// והחיסכון משמעותי כששיידר רץ על מסך מלא.\nfloat fbm(vec2 p){\n  float v = 0.0; float amp = 0.5;\n  for (int i = 0; i < 4; i++) { v += amp * noise(p); p *= 2.02; amp *= 0.5; }\n  return v;\n}\n\nuniform vec3 u_ripples[4];\n// זמן אמת שרץ תמיד. האדוות הן תגובה ישירה לנגיעה של המשתמש, ולכן הן\n// מתנגנות גם כשהתנועה כבויה, בניגוד לגלים שברקע.\nuniform float u_rtime;\n\nfloat surface(vec2 uv, float t){\n  float h = fbm(uv * 2.4 + vec2(t * 0.16, t * 0.09));\n  h += 0.5 * fbm(uv * 5.0 - vec2(t * 0.22, t * 0.13));\n\n  // האצבע גוררת מערבולת קטנה על פני המים\n  float d = length(uv - u_mouse);\n  h += 0.18 * sin(d * 26.0 - t * 5.0) * exp(-d * 5.5);\n\n  for (int i = 0; i < 4; i++){\n    float age = u_rtime - u_ripples[i].z;\n    if (u_ripples[i].z < 0.0 || age > 3.2) continue;\n    float dr = length(uv - u_ripples[i].xy);\n    float ring = sin(dr * 34.0 - age * 9.0);   // טבעת שמתרחקת ודועכת\n    h += 0.5 * ring * exp(-dr * 5.0) * exp(-age * 1.3);\n  }\n  return h;\n}\n\nvoid main(){\n  vec2 uv = (gl_FragCoord.xy - 0.5 * u_res.xy) / u_res.y;\n  float t = u_time;\n\n  // נורמל מקורב מהפרש גבהים. אפסילון גדול יחסית, אחרת הקאוסטיקה יוצאת\n  // רועשת ומקשה על קריאת הטקסט שמעליה.\n  float e = 0.02;\n  float hC = surface(uv, t);\n  float hX = surface(uv + vec2(e, 0.0), t);\n  float hY = surface(uv + vec2(0.0, e), t);\n  vec2 grad = vec2(hX - hC, hY - hC) / e;\n\n  vec3 deep    = vec3(0.008, 0.031, 0.070);\n  vec3 shallow = vec3(0.043, 0.157, 0.290);\n  vec3 foam    = vec3(0.545, 0.788, 0.965);\n  vec3 gold    = vec3(0.784, 0.643, 0.082);\n\n  float depth = smoothstep(-0.6, 1.8, hC);\n  vec3 col = mix(deep, shallow, depth);\n\n  // קאוסטיקה: קווי אור במקומות שבהם פני המים מתכנסים. עדין בכוונה —\n  // הרקע כאן משרת את הטקסט ולא להפך.\n  float caustic = pow(max(0.0, 1.0 - length(grad) * 0.55), 8.0);\n  col += foam * caustic * 0.20;\n\n  col += gold * smoothstep(1.05, 1.6, hC) * 0.14;   // הבהוב על פסגות הגלים\n\n  float vig = smoothstep(1.35, 0.2, length(uv));\n  col *= mix(0.35, 1.0, vig);\n\n  outColor = vec4(col, 1.0);\n}";

  var MAX_RIPPLES = 4;
  // vec3 לכל אדווה: x, y, וזמן הלידה. זמן שלילי פירושו משבצת ריקה.
  var ripples = new Float32Array(MAX_RIPPLES * 3).fill(-1);
  var slot = 0;
  var clock = 0;

  function drop(p) {
    var i = (slot % MAX_RIPPLES) * 3;
    ripples[i] = p.nx;
    ripples[i + 1] = p.ny;
    ripples[i + 2] = clock;
    slot++;
  }

  function uniforms(f, gl, uloc) {
    // שעון אמת שרץ תמיד. האדוות הן תגובה ישירה לנגיעה, ולכן הן
    // מתנגנות גם כשהתנועה האוטומטית כבויה.
    clock = f.t;
    gl.uniform1f(uloc('u_rtime'), f.t);
    gl.uniform3fv(uloc('u_ripples'), ripples);
  }

  mountShader(canvas, FRAGMENT, uniforms, drop);

  // מריץ fragment shader על מלבן שמכסה את הקנבס. מחזיר פונקציית עצירה,
  // או null אם אין WebGL2 — ואז נשאר רקע ה-CSS שמוגדר על הקנבס.
  function mountShader(canvas, fragment, extra, onDown) {
    var gl = canvas.getContext('webgl2', { antialias: false, alpha: true });
    if (!gl) return null;   // אין WebGL2 — נשארים עם רקע ה-CSS
  
    function compile(src, type) {
      var s = gl.createShader(type);
      gl.shaderSource(s, src);
      gl.compileShader(s);
      if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
        console.error('shader compile failed:', gl.getShaderInfoLog(s));
        return null;
      }
      return s;
    }
  
    var vs = compile('#version 300 es\nin vec2 a_pos;void main(){gl_Position=vec4(a_pos,0.0,1.0);}',
                     gl.VERTEX_SHADER);
    var fs = compile(fragment, gl.FRAGMENT_SHADER);
    if (!vs || !fs) return null;
  
    var prog = gl.createProgram();
    gl.attachShader(prog, vs);
    gl.attachShader(prog, fs);
    gl.linkProgram(prog);
    if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
      console.error('program link failed:', gl.getProgramInfoLog(prog));
      return null;
    }
    gl.useProgram(prog);
  
    var buf = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, buf);
    gl.bufferData(gl.ARRAY_BUFFER,
      new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]), gl.STATIC_DRAW);
    var loc = gl.getAttribLocation(prog, 'a_pos');
    gl.enableVertexAttribArray(loc);
    gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
  
    var cache = {};
    function uloc(name) {
      if (!(name in cache)) cache[name] = gl.getUniformLocation(prog, name);
      return cache[name];
    }
  
    // שעון שמתקדם רק כשהתנועה דלוקה, ונבדק בכל פריים כדי שמתג יגיב מיד.
    // הסמן נשאר חי תמיד, כי הוא מונע ישירות על ידי המשתמש.
    var clock = 0;
    var smooth = { x: 0, y: 0 };
  
    return mountStage(canvas, {
      // רקעים ערפיליים נראים זהים ב-dpr 1 ומתיחה ב-CSS, וזה חוסך
      // פי ארבעה עבודה במסכים צפופים.
      maxDpr: 1,
      onResize: function (w, h) {
        gl.viewport(0, 0, w, h);
        gl.uniform2f(uloc('u_res'), w, h);
      },
      onPointerDown: onDown,
      onFrame: function (f) {
        if (motionEnabled()) clock += f.dt;
        smooth.x += (f.pointer.nx - smooth.x) * 0.06;
        smooth.y += (f.pointer.ny - smooth.y) * 0.06;
        gl.uniform1f(uloc('u_time'), clock);
        gl.uniform2f(uloc('u_mouse'), smooth.x, smooth.y);
        if (extra) extra(f, gl, uloc);
        gl.drawArrays(gl.TRIANGLES, 0, 6);
      }
    });
  }

  // תשתית הקנבס: מדידת גודל, השהיה מחוץ למסך, מעקב אחרי המצביע ושעון dt חסום
  function mountStage(canvas, opts) {
    var dpr = Math.min(window.devicePixelRatio || 1, opts.maxDpr || 2);
    var pointer = { x: 0, y: 0, nx: 0, ny: 0, inside: false };
    var w = 0, h = 0, onScreen = true, painted = false, running = true, raf = 0;
  
    // ה-CSS לא בהכרח נטען כשהפונקציה רצה, ואז הקנבס נתקע בגודל 0.
    // לכן מודדים בכל פריים ולא רק כשמשנים גודל חלון.
    function syncSize() {
      var cw = Math.round(canvas.clientWidth * dpr);
      var ch = Math.round(canvas.clientHeight * dpr);
      if (!cw || !ch || (canvas.width === cw && canvas.height === ch)) return false;
      canvas.width = cw; canvas.height = ch; w = cw; h = ch;
      if (opts.onResize) opts.onResize(cw, ch, dpr);
      return true;
    }
  
    function readPointer(e) {
      var r = canvas.getBoundingClientRect();
      if (!r.width || !r.height) return;
      pointer.x = (e.clientX - r.left) * dpr;
      pointer.y = (e.clientY - r.top) * dpr;
      pointer.nx = (e.clientX - r.left - r.width / 2) / r.height;
      pointer.ny = (r.height / 2 - (e.clientY - r.top)) / r.height;
      pointer.inside = e.clientX >= r.left && e.clientX <= r.right &&
                       e.clientY >= r.top && e.clientY <= r.bottom;
      if (onScreen) painted = false;   // קלט מהמשתמש תמיד מצדיק ציור מחדש
    }
  
    function onMove(e) { readPointer(e); }
    function onDown(e) {
      readPointer(e);
      if (pointer.inside && opts.onPointerDown) opts.onPointerDown(pointer);
    }
    window.addEventListener('pointermove', onMove, { passive: true });
    window.addEventListener('pointerdown', onDown, { passive: true });
  
    // רץ רק כשהקנבס על המסך. בלי זה כמה אפקטים בדף אחד מתחרים על המעבד.
    var io = new IntersectionObserver(function (entries) { onScreen = entries[0].isIntersecting; });
    io.observe(canvas);
  
    var last = performance.now(), start = last;
  
    function loop() {
      if (!running) return;
      raf = requestAnimationFrame(loop);
      if (syncSize()) painted = false;
      if (!w || !h) return;
  
      // מחוץ למסך: מציירים פריים אחד כדי שלא יישאר ריק, ואז נעצרים
      var idle = document.hidden || !onScreen;
      if (idle && painted) return;
  
      var now = performance.now();
      var dt = Math.min((now - last) / 1000, 1 / 20);
      last = now;
      opts.onFrame({ t: (now - start) / 1000, dt: dt, w: w, h: h, dpr: dpr, pointer: pointer });
      painted = idle;
    }
    loop();
  
    return function () {
      running = false;
      cancelAnimationFrame(raf);
      io.disconnect();
      window.removeEventListener('pointermove', onMove);
      window.removeEventListener('pointerdown', onDown);
    };
  }
  
  // התנועה האוטומטית נתלית בתכונה על אלמנט השורש, כדי שמתג באתר יוכל לגבור
  // על הגדרת מערכת ההפעלה. תנועה שהמשתמש מייצר בעצמו פועלת תמיד.
  function motionEnabled() {
    if (document.documentElement.dataset.motion) return document.documentElement.dataset.motion !== 'off';
    return !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  }
})();
</script>