Mert Özen Her Satırda Daha İleri
Frontend JavaScript İleri — #14

JavaScript Canvas ile Çizim: 2D Context, Animasyon ve Piksel Verisi

Mert Özen 3 Kas 2026 10 dk 5 görüntülenme
JavaScript Canvas ile Çizim: 2D Context, Animasyon ve Piksel Verisi

Canvas'ın 2D context'iyle şekil, yol, gradient ve metin çiziyor, görselleri piksel piksel işliyor, requestAnimationFrame ile animasyon döngüsü kuruyor ve retina ekranlarda bulanıklığı çözüyoruz.

Sürüm notu: Bu yazıdaki kodlar ES2026 ve güncel tarayıcılar (Chrome, Edge, Firefox, Safari) içindir.

Önce Tahmin Et

Bir canvas element'i oluşturuyor, boyutuna bakıyor, sonra CSS ile genişletip tekrar bakıyoruz:

const canvas = document.createElement("canvas");
console.log(canvas.width, canvas.height);

canvas.style.width = "600px";
console.log(canvas.width);

Bu kod konsola ne yazdırır? Hiç boyut vermediğimiz bir canvas'ın genişliği nedir, CSS ile 600 piksel yaptıktan sonra canvas.width ne olur? Okumaya devam etmeden önce bir tahmin yap. Cevabı, canvas'ın neden bazen bulanık göründüğünü konuşurken vereceğiz.

Tuval ve Fırça: Canvas'ın Çalışma Mantığı

Şimdiye kadar sayfaya bir şey eklemek istediğimizde DOM'a element koyduk. Bir butonu sonradan taşıyabilir, rengini değiştirebilir, tıklamasını dinleyebiliriz; tarayıcı her element'i hatırlıyor. Canvas bambaşka çalışır. DOM bir mıknatıslı tahta gibidir, parçaları istediğin zaman yerinden oynatırsın. Canvas ise boyanmış bir duvar gibidir: boya kuruduğunda o daire artık bir "object" değil, sadece renkli piksellerdir. Daireyi taşımak istiyorsan duvarı silip yeniden boyarsın.

Bu modele immediate mode denir ve canvas'ın gücü de buradan gelir. Binlerce şekli DOM element'i olarak tutmak pahalıdır; canvas'ta ise her biri birkaç piksel boyamaktan ibaret. Oyunlar, grafikler, görsel düzenleyiciler bu yüzden canvas kullanır.

Canvas'ta çizmek için önce ondan bir context, yani fırça istersin. Aşağıdaki dosyayı kaydedip aç:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Canvas</title>
  <style>canvas { border: 1px solid #ccc; }</style>
</head>
<body>
  <canvas id="canvas" width="400" height="250">Your browser doesn't support canvas.</canvas>
  <script>
    const canvas = document.getElementById("canvas");
    const ctx = canvas.getContext("2d");

    ctx.fillStyle = "#2563eb";
    ctx.fillRect(20, 20, 120, 80);

    ctx.strokeStyle = "crimson";
    ctx.lineWidth = 4;
    ctx.strokeRect(170, 20, 120, 80);

    ctx.clearRect(50, 40, 40, 40);
  </script>
</body>
</html>

Koordinat sisteminin başlangıcı sol üst köşedir ve y aşağı doğru büyür; okulda gördüğün grafiğin tersi. fillRect(x, y, width, height) içi dolu bir dikdörtgen çizer, strokeRect sadece kenarını, clearRect ise o alanı silip şeffaf yapar. Mavi dikdörtgenin ortasında açılan delik bu yüzden.

Dikkat et, rengi çizim fonksiyonuna parametre olarak vermiyoruz. fillStyle, strokeStyle ve lineWidth context'in durumudur; ressamın elindeki fırça gibi, değiştirene kadar aynı kalır. Geçici bir değişiklik yapıp eski hâline dönmek istersen ctx.save() o anki durumu bir kenara kaydeder, ctx.restore() geri getirir. <canvas> etiketinin içine yazdığımız metin ise canvas'ı gösteremeyen ortamlarda görünen yedek içeriktir.

Yollar: Çizgi, Üçgen ve Daire

Dikdörtgen dışındaki her şeyi yol (path) ile çizersin. Yol, kalemi kâğıttan kaldırmadan çizdiğin bir taslak gibidir; fill() ya da stroke() diyene kadar ekranda hiçbir şey görünmez. Bu satırları yukarıdaki dosyanın script'ine, ctx tanımının altına ekle:

// Üçgen
ctx.beginPath();
ctx.moveTo(60, 230);
ctx.lineTo(140, 230);
ctx.lineTo(100, 160);
ctx.closePath();
ctx.fillStyle = "seagreen";
ctx.fill();

// Daire
ctx.beginPath();
ctx.arc(250, 195, 35, 0, Math.PI * 2);
ctx.strokeStyle = "black";
ctx.lineWidth = 3;
ctx.stroke();

beginPath() yeni bir taslak açar. moveTo kalemi kaldırıp bir noktaya götürür, lineTo oradan çizgi çeker, closePath son noktayı ilk noktaya bağlar. arc(x, y, radius, startAngle, endAngle) merkezi ve yarıçapı verilen bir yay çizer. Açılar derece değil radyan cinsinden: tam tur Math.PI * 2, yarım tur Math.PI. Yarım daire istiyorsan bitiş açısını Math.PI yapman yeterli.

Renk, Gradient ve Metin

fillStyle'a CSS'te kullandığın her renk biçimini verebilirsin: "tomato", "#ff6347", "rgb(255 99 71 / 50%)". Renk yerine bir gradient da verebilirsin. Bunu da aynı script'e ekleyip dene:

const gradient = ctx.createLinearGradient(0, 0, 400, 0);
gradient.addColorStop(0, "#f97316");
gradient.addColorStop(1, "#7c3aed");
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, 400, 250);

ctx.fillStyle = "white";
ctx.font = "bold 32px system-ui";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("Hello Canvas", 200, 125);

createLinearGradient(x0, y0, x1, y1) iki nokta arasında bir geçiş tanımlar; burada soldan sağa. addColorStop 0 ile 1 arasındaki konumlara renk koyar. Daireden dışa doğru bir geçiş için createRadialGradient var. Metinde font CSS'teki font kısaltmasıyla aynı yazılır. textAlign ve textBaseline ise verdiğin noktanın metnin neresine denk geleceğini belirler; ikisini center ve middle yapınca metin tam o noktaya ortalanır. Bu gradient bütün tuvali kapladığı için önceki şekilleri örttü. Canvas'ta sıra önemlidir: sonra çizilen, öncekinin üstüne boyanır.

Görsel Çizmek ve Piksellere Dokunmak

drawImage bir görseli, bir videonun o anki karesini ya da başka bir canvas'ı tuvale çizer. Önceki yazıda dosyaları byte'larına kadar okumuştuk; şimdi kullanıcının seçtiği görseli çizip gri tonlamaya çevirelim:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Grayscale</title>
  <style>canvas { display: block; margin-top: 12px; border: 1px solid #ccc; }</style>
</head>
<body>
  <input type="file" id="picker" accept="image/*">
  <button id="gray" disabled>Make it gray</button>
  <canvas id="canvas" width="400" height="300"></canvas>
  <script>
    const canvas = document.getElementById("canvas");
    const ctx = canvas.getContext("2d");
    const grayButton = document.getElementById("gray");

    document.getElementById("picker").addEventListener("change", async (event) => {
      const img = new Image();
      img.src = URL.createObjectURL(event.target.files[0]);
      await img.decode(); // görsel hazır olmadan çizersek hiçbir şey görünmez
      URL.revokeObjectURL(img.src);

      const ratio = Math.min(canvas.width / img.naturalWidth, canvas.height / img.naturalHeight);
      const w = img.naturalWidth * ratio;
      const h = img.naturalHeight * ratio;
      ctx.clearRect(0, 0, canvas.width, canvas.height);
      ctx.drawImage(img, (canvas.width - w) / 2, (canvas.height - h) / 2, w, h);
      grayButton.disabled = false;
    });

    grayButton.addEventListener("click", () => {
      const image = ctx.getImageData(0, 0, canvas.width, canvas.height);
      const d = image.data; // R, G, B, A, R, G, B, A, ...

      for (let i = 0; i < d.length; i += 4) {
        const gray = 0.299 * d[i] + 0.587 * d[i + 1] + 0.114 * d[i + 2];
        d[i] = d[i + 1] = d[i + 2] = gray;
      }
      ctx.putImageData(image, 0, 0);
    });
  </script>
</body>
</html>

drawImage(img, x, y, width, height) görseli istediğin boyuta ölçekleyerek çizer. Oranı hesaplayıp görseli bozmadan tuvale sığdırıyor ve ortalıyoruz. img.decode() görselin çözülüp çizilmeye hazır olduğu anda resolve olan bir promise döndürür; load event'ini beklemenin modern yolu.

Asıl ilginç kısım getImageData. Bu method tuvalin bir bölgesini piksel piksel bir ImageData object'i olarak verir. data alanı, bir önceki yazıda tanıştığımız Uint8ClampedArray. Her piksel için dört byte art arda dizilir: kırmızı, yeşil, mavi ve saydamlık (alpha), hepsi 0–255 arası. 400 × 300'lük bir tuvalde bu 480.000 byte demek. Döngüde dörder dörder ilerleyip her pikselin üç rengini aynı gri değere eşitliyoruz. Formüldeki katsayılar insan gözünün yeşile daha duyarlı olmasından geliyor. Hesapladığımız değer küsuratlı ama sorun değil; clamped array onu yuvarlayıp 0–255 aralığında tutuyor. putImageData de değiştirdiğimiz pikselleri tuvale geri yazıyor.

requestAnimationFrame ile Animasyon

Canvas'ta animasyon, çocukken defterin köşesine çizdiğin çizgi filmlere benzer. Her sayfada top biraz daha ileridedir; sayfaları hızlı çevirdiğinde hareket görürsün. Canvas'ta da her karede üç adım tekrarlanır: durumu güncelle, tuvali temizle, yeniden çiz. Bu döngüyü daha önce event loop ve performans yazılarında gördüğümüz requestAnimationFrame sürer:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Bouncing circle</title>
  <style>canvas { border: 1px solid #ccc; }</style>
</head>
<body>
  <canvas id="canvas" width="400" height="250"></canvas>
  <script>
    const canvas = document.getElementById("canvas");
    const ctx = canvas.getContext("2d");
    const circle = { x: 50, y: 50, r: 15, vx: 180, vy: 120 }; // hız: piksel/saniye
    let lastTime = performance.now();

    function frame(now) {
      const dt = Math.min((now - lastTime) / 1000, 0.05); // saniye, en fazla 50 ms
      lastTime = now;

      // 1. Güncelle
      circle.x += circle.vx * dt;
      circle.y += circle.vy * dt;
      if (circle.x < circle.r || circle.x > canvas.width - circle.r) {
        circle.vx *= -1;
        circle.x = Math.min(Math.max(circle.x, circle.r), canvas.width - circle.r);
      }
      if (circle.y < circle.r || circle.y > canvas.height - circle.r) {
        circle.vy *= -1;
        circle.y = Math.min(Math.max(circle.y, circle.r), canvas.height - circle.r);
      }

      // 2. Temizle, 3. Çiz
      ctx.clearRect(0, 0, canvas.width, canvas.height);
      ctx.beginPath();
      ctx.arc(circle.x, circle.y, circle.r, 0, Math.PI * 2);
      ctx.fillStyle = "tomato";
      ctx.fill();

      requestAnimationFrame(frame);
    }

    requestAnimationFrame(frame);
  </script>
</body>
</html>

requestAnimationFrame callback'ini tarayıcının bir sonraki ekran yenilemesinden hemen önce çağırır ve ona performance.now() ile aynı ölçekte bir zaman damgası verir. Hızı "kare başına piksel" değil "saniye başına piksel" olarak tutup iki kare arasında geçen süreyle (dt) çarpıyoruz. Böylece 60 Hz bir ekranda da 120 Hz bir ekranda da daire aynı hızda hareket eder. Sekme arka plana geçtiğinde tarayıcı bu callback'i durdurur; geri döndüğünde dt çok büyük gelmesin diye onu 50 milisaniyeyle sınırlıyoruz. Kenara çarpınca hızı tersine çevirip daireyi sınırın içine geri itiyoruz; yoksa sınırı biraz aşan daire her karede yön değiştirip duvara yapışabilirdi.

Değişken adlarına da bir bak. Kenar kontrollerinde üst sınır için top adında bir sabit tanımlamak çok doğal gelirdi ama const top yazsaydın sayfa "Identifier 'top' has already been declared" hatasıyla açılmazdı. window.top tarayıcıda zaten var ve normal bir script'in en üst seviyesinde let ya da const ile aynı isimde bir değişken tanımlayamazsın. Kısa, yaygın isimler seçerken ara sıra karşına çıkan küçük bir sürpriz.

Keskin Görüntü: devicePixelRatio

Baştaki sorunun cevabı: 300 150, ardından yine 300. Canvas'ın iki ayrı boyutu var. width ve height attribute'ları çizim tamponunun, yani gerçekten boyanan piksel ızgarasının boyutudur; vermezsen varsayılanı 300 × 150. CSS'teki genişlik ise o ızgaranın ekranda kaç piksellik alana yayılacağını belirler. CSS ile 600 piksel yaptığında ızgara büyümez, gerilir. 300 piksellik bir fotoğrafı iki katına büyütmek gibi; sonuç bulanıktır.

Aynı sorun retina ekranlarda kendiliğinden ortaya çıkar. Bu ekranlarda bir CSS pikseli iki ya da üç fiziksel piksele denk gelir ve oran window.devicePixelRatio içinde durur. Çizim tamponunu bu oranla büyütür, CSS boyutunu sabit tutarsan çizim keskinleşir:

function makeSharp(canvas, cssWidth, cssHeight) {
  const dpr = window.devicePixelRatio || 1;
  canvas.style.width = `${cssWidth}px`;
  canvas.style.height = `${cssHeight}px`;
  canvas.width = Math.round(cssWidth * dpr);
  canvas.height = Math.round(cssHeight * dpr);

  const ctx = canvas.getContext("2d");
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // bundan sonra CSS pikseliyle çiz
  return ctx;
}

const ctx = makeSharp(document.getElementById("canvas"), 400, 250);

setTransform ile bütün çizimleri dpr kadar ölçekliyoruz; böylece koordinatları CSS pikseli olarak düşünmeye devam ediyorsun, fillRect(20, 20, 120, 80) her ekranda aynı yerde ve aynı büyüklükte, ama keskin çıkıyor. scale() yerine setTransform kullanmamızın sebebi, fonksiyon ikinci kez çağrılırsa ölçeğin üst üste binmemesi.

Sık Düşülen Tuzaklar

beginPath() çağırmayı unutmak en sık rastlanan hata. Yeni bir şekle başlamadan taslağı sıfırlamazsan önceki şekiller de taslakta kalır ve bir sonraki stroke() ya da fill() hepsini yeni renkle tekrar boyar. Animasyonda bu, her karede büyüyen bir taslak ve gittikçe yavaşlayan bir sayfa demek.

canvas.width ya da canvas.height'a değer atamak, aynı değeri atasan bile, tuvali tamamen temizler ve context'in bütün durumunu sıfırlar: renkler, kalınlık, ölçek. Boyutu değiştirdikten sonra stilleri ve setTransform'u yeniden ayarla.

Görsel yüklenmeden drawImage çağırırsan hata almazsın, sadece hiçbir şey çizilmez. Sessiz başarısızlık en zor bulunanıdır; her zaman decode() ya da load event'ini bekle.

Başka bir kaynaktan, CORS izni olmadan gelen bir görseli çizersen canvas "kirlenir" (tainted). Çizim yine görünür, ama getImageData bir SecurityError fırlatır; tarayıcı başka sitenin görselinin piksellerini okumana izin vermez. Kullanıcının seçtiği dosyalar bu sorunu yaşatmaz, çünkü blob: adresi senin sayfana aittir.

getImageData'yı her karede çağırmak pahalıdır, çünkü tarayıcı pikselleri ekran kartından geri okumak zorunda kalabilir. Sık okuyacaksan context'i canvas.getContext("2d", { willReadFrequently: true }) ile iste; bu, tarayıcıya okumaya uygun bir yol seçmesi için ipucu verir.

Son olarak erişilebilirlik: canvas'ın içine çizdiğin metin ve şekiller ekran okuyuculara görünmez. Önemli bir bilgi taşıyorsa aynı bilgiyi yedek içerik olarak ya da canvas'ın yanında metin olarak da ver.

Küçük Bir Deneme

★☆☆ İlk örnekteki dosyada fillRect ve bir yol kullanarak basit bir ev çiz: kare bir gövde, üçgen bir çatı, dikdörtgen bir kapı ve yuvarlak bir pencere.

★★☆ Çalıştırmadan önce iki karenin renklerini tahmin et, sonra dene:

ctx.fillStyle = "red";
ctx.save();
ctx.fillStyle = "blue";
ctx.fillRect(0, 0, 50, 50);
ctx.restore();
ctx.fillRect(60, 0, 50, 50);

★★★ Zıplayan daire örneğini genişlet: canvas'a her tıklamada, tıklanan noktada rastgele renkli ve rastgele hızlı yeni bir daire ekle. Daireleri bir array'de tut ve her karede hepsini güncelleyip çiz. İpucu: tıklama koordinatı için event'in offsetX ve offsetY alanlarına bak.

Canvas'ta her pikseli kendimiz çizdik ve kendimiz yönettik; tarayıcı orada ne olduğunu bilmiyordu. Bir sonraki yazıda tam tersi yöne gidiyoruz: tarayıcının tanıdığı, DOM'da yaşayan, kendi stili ve davranışı olan yeni HTML etiketleri tanımlayacağız. Sırada Custom Elements, Shadow DOM ve slot'lar var.