Home

14. Super Famicom · 1990 · Retro techniques

Mode 7 floor

Demos are animating. Following your system setting.
Colors and knobs

These rewrite the live file. Copy HTML picks up whatever you set.

About

For each scanline below the horizon, a distance is recovered from camera height and the line is a slice of a 2D texture. That is the SNES's Mode 7: no mesh, just a perspective divide on Y. The pylons are scaled billboards in the Space Harrier manner rather than anything Mode 7 did itself. Move the pointer to yaw.

Move the pointer left or right to steer.

Reference

Nintendo Super Famicom background Mode 7, 1990, as used for F-Zero's track

Browser APIs

  • Canvas 2D
  • ImageData
  • Pointer Events
  • requestAnimationFrame
  • matchMedia

If WebGL is missing, the demo draws a message on the canvas instead of a blank frame. By default a demo holds a still frame when your system asks for reduced motion; the Motion buttons override that, and a standalone copy animates with #motion=play on its URL.

Source

(function () {
  const PARAMS = {
    color: "#6ee7b7",
    speed: 1,
    scale: 1
  };
  function hexToRgb(hex) {
    const n = parseInt(String(hex).replace("#", ""), 16);
    if (isNaN(n)) return [110, 231, 183];
    return [n >> 16 & 255, n >> 8 & 255, n & 255];
  }

  const canvas = document.getElementById("c");
  const fallback = document.getElementById("fallback");
  const ctx = canvas.getContext("2d");
  if (!ctx) {
    fallback.hidden = false;
    fallback.textContent = "Canvas 2D is not available in this browser.";
    return;
  }

  const buf = document.createElement("canvas");
  const BW = 320, BH = 180;
  buf.width = BW; buf.height = BH;
  const bctx = buf.getContext("2d");
  const img = bctx.createImageData(BW, BH);
  const px = img.data;

  let camX = 0, camZ = 0, yaw = 0;
  let look = 0;
  let last = 0;
  let raf = 0;
  const motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
  let reduced = motionQuery.matches;
  const CAM_H = 1.15;

  canvas.addEventListener("pointermove", function (ev) {
    const r = canvas.getBoundingClientRect();
    look = ((ev.clientX - r.left) / r.width - 0.5) * 1.2;
    wake();
  });
  canvas.addEventListener("pointerleave", function () { look = 0; wake(); });

  function draw() {
    const rgb = hexToRgb(PARAMS.color);
    const horizon = (BH * 0.42) | 0;
    for (let i = 0; i < px.length; i += 4) {
      const y = (i / 4 / BW) | 0;
      if (y < horizon) {
        const t = y / horizon;
        px[i] = 28 + t * 40; px[i + 1] = 36 + t * 50; px[i + 2] = 58 + t * 40; px[i + 3] = 255;
      }
    }
    const sunX = BW * 0.7, sunY = horizon * 0.45;
    for (let y = 0; y < horizon; y++) {
      for (let x = 0; x < BW; x++) {
        if (Math.hypot(x - sunX, y - sunY) < 6) {
          const i = (y * BW + x) * 4;
          px[i] = 255; px[i + 1] = 220; px[i + 2] = 140;
        }
      }
    }

    const view = yaw + look * 0.35;
    const cs = Math.cos(view), sn = Math.sin(view);
    const cell = 1.35 / PARAMS.scale;
    const fogFar = 42;
    for (let y = horizon; y < BH; y++) {
      const row = (y - horizon + 1) / BH;
      const dist = CAM_H / row;
      const fog = Math.max(0.08, 1 - dist / fogFar);
      const halfW = dist * 0.92;
      for (let x = 0; x < BW; x++) {
        const across = ((x / BW) - 0.5) * 2 * halfW;
        const wx = camX + cs * dist - sn * across;
        const wz = camZ + sn * dist + cs * across;
        const cx = Math.floor(wx / cell);
        const cz = Math.floor(wz / cell);
        const on = (cx + cz) & 1;
        const stripe = ((cx & 7) === 0 || (cz & 7) === 0) ? 1.18 : 1;
        const r = ((on ? rgb[0] : rgb[0] * 0.18) * stripe) * fog + 22 * (1 - fog);
        const g = ((on ? rgb[1] : rgb[1] * 0.18) * stripe) * fog + 26 * (1 - fog);
        const b = ((on ? rgb[2] : rgb[2] * 0.22) * stripe) * fog + 32 * (1 - fog);
        const i = (y * BW + x) * 4;
        px[i] = r; px[i + 1] = g; px[i + 2] = b; px[i + 3] = 255;
      }
    }

    bctx.putImageData(img, 0, 0);
    ctx.imageSmoothingEnabled = false;
    ctx.drawImage(buf, 0, 0, canvas.width, canvas.height);

    const pylons = [
      [4, 6], [8, -3], [-5, 10], [12, 8], [-8, -6], [2, -11], [-10, 3]
    ];
    ctx.save();
    const scaleY = canvas.height / BH;
    const scaleX = canvas.width / BW;
    ctx.scale(scaleX, scaleY);
    for (let p = 0; p < pylons.length; p++) {
      const ox = pylons[p][0] * cell - camX;
      const oz = pylons[p][1] * cell - camZ;
      const lz = ox * cs + oz * sn;
      const lx = -ox * sn + oz * cs;
      if (lz < 0.6 || lz > fogFar) continue;
      const sx = BW * 0.5 + (lx / lz) * (BW * 0.5 / 0.92);
      const sy = horizon + (CAM_H / lz) * BH;
      const ph = (1.4 / lz) * BH;
      const pw = ph * 0.18;
      const fog = Math.max(0.15, 1 - lz / fogFar);
      ctx.fillStyle = "rgba(" + rgb[0] + "," + rgb[1] + "," + rgb[2] + "," + fog + ")";
      ctx.fillRect(sx - pw * 0.5, sy - ph, pw, ph);
    }
    ctx.restore();
  }

  function resize() {
    const dpr = Math.min(window.devicePixelRatio || 1, 2);
    const w = Math.max(1, Math.floor(canvas.clientWidth * dpr));
    const h = Math.max(1, Math.floor(canvas.clientHeight * dpr));
    if (canvas.width !== w || canvas.height !== h) {
      canvas.width = w;
      canvas.height = h;
    }
  }

  function frame(now) {
    const dt = Math.min(0.05, last ? (now - last) * 0.001 : 0.016);
    last = now;
    if (!reduced) {
      yaw += look * dt * 1.6;
      camX += Math.cos(yaw) * 4.2 * PARAMS.speed * dt;
      camZ += Math.sin(yaw) * 4.2 * PARAMS.speed * dt;
    }
    resize();
    draw();
    if (!reduced) raf = requestAnimationFrame(frame);
  }

  // Motion. The system's reduced-motion preference decides by default. A
  // standalone copy animates anyway with "#motion=play" in its URL (or holds a
  // frame with "#motion=still"); an embedding page can post {motion: "play"}.
  let motionMode = "auto";
  function applyMotion() {
    const next = motionMode === "still" || (motionMode === "auto" && motionQuery.matches);
    if (next === reduced) return;
    reduced = next;
    last = 0;
    if (reduced) { cancelAnimationFrame(raf); raf = 0; frame(performance.now()); }
    else if (!raf) raf = requestAnimationFrame(frame);
  }
  function setMotion(mode) {
    if (mode === "play" || mode === "still" || mode === "auto") { motionMode = mode; applyMotion(); }
  }
  function readMotionHash() {
    const m = /motion=(play|still|auto)/.exec(location.hash);
    let mode = m ? m[1] : "auto";
    // Embedded in the gallery, the page sets window.__demoMotion before any frame loads.
    try { if (!m && window.parent !== window && typeof window.parent.__demoMotion === "string") mode = window.parent.__demoMotion; } catch (e) { /* cross-origin parent */ }
    setMotion(mode);
  }
  function wake() { if (reduced) frame(performance.now()); }
  if (motionQuery.addEventListener) motionQuery.addEventListener("change", applyMotion);
  else if (motionQuery.addListener) motionQuery.addListener(applyMotion);
  window.addEventListener("hashchange", readMotionHash);
  window.addEventListener("message", function (e) { if (e.data && typeof e.data.motion === "string") setMotion(e.data.motion); });
  window.addEventListener("resize", wake);

  if (reduced) frame(performance.now());
  else raf = requestAnimationFrame(frame);
  readMotionHash();
})();