İçeriğe geç
Zahit Egemen İm
Tüm notlar

Bu sitedeki ASCII portre nasıl çalışıyor

Fotoğrafı karakterlere çeviren, sonra yeniden fotoğrafa döndüren küçük bir canvas çizimi: ton rampası, gözlerden açılan dalga ve kare bırakmayan geçiş.

Bu sitenin ana sayfasındaki portre bir fotoğraf değil, bir <canvas>. Sayfa açılınca yüzüm gözlerden başlayarak karakterlerle yazılıyor, bir süre ASCII olarak kalıyor, sonra yavaşça fotoğrafa dönüyor. İmleci üstünde gezdirince altındaki fotoğraf yeniden koda dönüşüyor. Kütüphane yok; yaklaşık 900 satır TypeScript.

Bu notta nasıl çalıştığını anlatıyorum. Fikir basit, ama iyi görünmesi için birkaç küçük hile gerekti.

Fotoğrafı hücrelere bölmek

Önce fotoğrafın arka planını sildim; portre şeffaf bir WebP. Kanvası 4,75 piksellik hücrelere bölüyorum. Bir karakter, eninin yaklaşık 1,7 katı yüksekliğinde, o yüzden hücreler de dikdörtgen. Her hücre fotoğraftan 4×4 noktayla örnekleniyor ve üç şey hesaplanıyor:

  • Parlaklık, yani hücrenin ne kadar koyu olduğu
  • Kenar: Sobel filtresiyle, hücrede belirgin bir çizgi var mı
  • Kişiye ait olma: şeffaflıktan, hücre portrenin içinde mi

Parlaklık bir ton rampasıyla karaktere çevriliyor:

const RAMP = " .-:;*+x#%@";

Açık bir sayfada koyu pikseller yoğun karakter (@, %) alıyor, koyu sayfada tersi. Tema değişince rampa yeniden hesaplanıyor.

Yüzün okunması

İlk denemede yüz tanınmıyordu. Saç ve ceket koyu olduğu için bütün yoğun karakterleri onlar kapıyor, yüz neredeyse boş kalıyordu. Çözüm, yüzü ayrı ele almak oldu. Baş bölgesi için bir elips tanımladım; elipsin içindeki hücreleri kendi aralarında sıralayıp rampaya eşit dağıtıyorum. Böylece göz, kaş, gözlük ve ağız, ışık nasıl olursa olsun rampanın tamamını kullanıyor. Elipsin dışında da kenarlara biraz ağırlık veriyorum, çizgiler kaybolmasın diye.

Hızlı çizmek

Her karakteri tek tek fillText ile çizmek yavaş. Bunun yerine her satırı tek bir metin olarak çiziyorum. Ama ton bantları farklı opaklıkta olduğu için her satırı altı ayrı "katmana" ayırıyorum (üç ton bandı, mercek, dalga cephesi ve soluk noktalar). Her katman, kendi karakterleri dışında boşluk olan bir satır. Satır başına en fazla altı çağrı ediyor.

Küçük bir tuzak: Geist Mono'nun programlama ligatürleri var; == ya da <> yan yana gelince tek bir işarete dönüşüyor ve hizalama kayıyor. textRendering = "optimizeSpeed" ve neredeyse sıfır bir letterSpacing ikisi birden ligatürleri kapatıyor.

Gözlerden açılan dalga

Her hücrenin bir gecikmesi var: gözlere olan uzaklığı, artı biraz rastgelelik. Dalga bir hücreye gelmeden hemen önce orada soluk bir nokta beliriyor. Dalga gelince bir anlığına rastgele bir karakter, sonra hücrenin kendi karakteri tam koyulukta görünüyor ve çeyrek saniyede kendi tonuna iniyor. Yüz bu yüzden "yazılıyor" gibi görünüyor.

Bu animasyon başlık yazısıyla aynı saatte çalışıyor. CSS animasyonunun başlangıç zamanını okuyorum:

const animation = document.querySelector("[data-hero-clock]")?.getAnimations()[0];
await animation?.ready;
const t0 = animation?.startTime; // requestAnimationFrame ile aynı zaman tabanı

Yavaş bir telefonda kanvas geç hazır olursa animasyon atlanmıyor; baştan, biraz daha hızlı oynuyor.

Kare bırakmayan geçiş

ASCII'den fotoğrafa geçişin ilk sürümü kötüydü. Her hücrenin fotoğrafını dört kademede açıyordum ve geçiş anı sansür mozaiği gibi görünüyordu. Şimdi her karede hücre başına bir piksellik küçük bir maske çiziyorum: her pikselin opaklığı, o hücrenin ne kadar fotoğrafa döndüğü. Sonra bu maskeyi yumuşatarak kanvas boyutuna büyütüp fotoğrafla birleştiriyorum:

ctx.drawImage(photo, 0, 0, width, height);
ctx.globalCompositeOperation = "destination-in";
ctx.drawImage(mask, 0, 0, width, height); // 84×50 piksellik maske, büyütülürken yumuşar
ctx.globalCompositeOperation = "source-over";

Büyütme sırasındaki yumuşatma, kenarları kendiliğinden yumuşak bir geçişe çeviriyor. Karakterler de bu sırada rampadan aşağı inceliyor (@ → # → + → : → .), böylece kod ile fotoğraf bir an çift pozlama gibi üst üste duruyor.

Mercek ve durmak

İmleç portrenin üstündeyken altındaki hücreler "ısınıyor" ve ısı 400 milisaniyede sönüyor. Isınan hücrede fotoğraf aynı maskeyle geri çekiliyor, yerine kod karakterleri geliyor. Hızlı fare hareketlerinde aradaki noktaları da dolduruyorum ki iz kopuk kalmasın. Telefonda dokunuş, dışa yayılan tek bir kod halkası gönderiyor.

En sevdiğim kısım belki de en sıkıcı olanı: her şey fotoğrafa dönüp imleç de durunca çizim tamamen duruyor. Boşta tek bir kare bile çizilmiyor. Sekme arka plandaysa ya da portre ekranda değilse de çizim yok. "Hareketi azalt" ayarı açıksa portre baştan durgun bir fotoğraf.

Altındaki "ascii ─●─ foto" anahtarı da bu çizimden besleniyor: nokta, portrenin ne kadarının fotoğraf olduğunu gösteriyor. Tıklayınca bütün portre gözlerden başlayan bir dalgayla koda dönüyor.