← Tất cả bài viết

[ Blog ]

9 tháng 7, 2026

11 phút đọc

Hiệu Ứng Halftone Dot Grid Ảnh Bị Mờ Nhạt? Cách Sửa

Hiệu ứng halftone dot grid ảnh bị mờ, nhạt màu? Cách sửa tận gốc — pre-mask nền vào alpha và nâng midtone trước khi map tone ra kích thước dot.

WebGLCanvasShadersParticlesHalftoneCreative Coding
Hiệu Ứng Halftone Dot Grid Ảnh Bị Mờ Nhạt? Cách Sửa

Tháng trước tôi dựng một hiệu ứng particle kiểu halftone/LED-wall — một lưới dot mà kích thước và độ mờ (opacity) của từng dot ánh xạ theo độ sáng của một tấm ảnh nguồn, kiểu hiệu ứng bạn thấy hình dạng dần "hiện ra" từ nhiễu trên hàng trăm hero section Awwwards. Code đúng. Toán đúng. Kết quả thì như một bóng ma: một vệt xám nhạt lờ mờ, dù tôi có tăng contrast trong ảnh nguồn lên cỡ nào thì nó cũng không bao giờ đậm hơn nổi. Nếu hiệu ứng halftone dot grid ảnh của bạn bị mờ nhạt, bạn đang dính đúng cái bẫy tôi từng dính — và cách sửa không nằm ở shader vẽ dot, mà nằm ở cách bạn sample ảnh ngay từ đầu.

Đây là bài viết tôi ước gì có sẵn lúc tôi ngồi cả buổi chiều tự hỏi "sao cái này không chịu đậm lên". Hai vấn đề trung bình hóa (averaging) độc lập chồng lên nhau, và chừng nào chưa hiểu cả hai, có chỉnh shader kiểu gì cũng vô ích.

Vì sao mapping độ sáng ảnh sang particle grid quá mờ

Một lưới halftone/particle cần đúng một giá trị tone cho mỗi cell — một con số độ sáng cho một patch 20×20px của ảnh nguồn, không phải 400 pixel riêng lẻ. Cách rẻ và đúng để lấy con số đó là box average: vẽ ảnh nguồn ở đúng kích thước nó chiếm trên màn hình, rồi làm MỘT lần drawImage downscale vào một canvas nhỏ cols × rows với imageSmoothingEnabled bật. Bộ compositor của canvas/GPU tự làm phép trung bình hộ bạn — mỗi pixel output là trung bình phần cứng của đúng một cell pitch×pitch. Không cần vòng lặp duyệt từng pixel thủ công, không cần tính getImageData cho từng cell.

// 1. Vẽ ảnh nguồn vào một canvas cỡ viewport, đúng khung nó chiếm trên màn.
const full = document.createElement('canvas');
full.width = viewportWidth;
full.height = viewportHeight;
const fullCtx = full.getContext('2d');
fullCtx.drawImage(sourceImage, boxX, boxY, boxWidth, boxHeight);

// 2. Một lần downscale vào canvas đích cols x rows — ĐÂY chính là box average.
const cols = 64;
const rows = 40;
const small = document.createElement('canvas');
small.width = cols;
small.height = rows;
const smallCtx = small.getContext('2d');
smallCtx.imageSmoothingEnabled = true;
smallCtx.imageSmoothingQuality = 'high';
smallCtx.drawImage(full, 0, 0, cols, rows);

// 3. Đọc tone của từng cell.
const { data } = smallCtx.getImageData(0, 0, cols, rows);

Phần này đúng, và hầu hết tutorial dừng lại đúng ở đây — cũng chính vì thế mà hiệu ứng bị mờ nhạt. Vấn đề là tone đã trung bình hóa không phải là tone hiển thị. Hai thứ đang chống lại bạn:

1. Box-averaging kéo mọi tone về gần giá trị trung bình. Các cell nằm ngay viền silhouette chỉ được phủ một phần bởi vùng tối, nên nó trung bình hóa theo cả những gì nằm phía sau. Các cell bên trong cũng hiếm khi chạm tới 1.0 sạch — ảnh thật không có những khối đen/trắng phẳng đúng cỡ một grid cell, nên gần như mọi giá trị sample đều rơi vào một dải midrange hẹp. Đưa thẳng dải đó vào một phép map tuyến tính tone → alpha hay tone → dotSize thì cả tấm ảnh sẽ hiện ra như một vệt xám đều, không bao giờ chạm tới cực trị để trông "đậm, rõ".

2. Nền đã "xóa" vẫn là pixel không trong suốt. Nếu ảnh nguồn của bạn là một chủ thể được cắt ra trên nền trắng hoặc gần trắng (kiểu export thường gặp từ công cụ xóa nền), những pixel trắng đó KHÔNG trong suốt — chúng chỉ là RGB sáng màu, alpha đầy đủ. Khi phép downscale trung bình hóa một cell nằm ngay viền chủ thể, màu trắng của nền sẽ lẫn thẳng vào tone của chủ thể — đây chính là thứ tạo ra cái "quầng sáng" mờ quanh silhouette thay vì một viền sạch.

Hai vấn đề này cộng dồn: giữa thì mờ, viền thì lem. Chỉ sửa một trong hai thì bạn mới đi được nửa đường.

Cách sửa: đẩy tone vào alpha trước khi trung bình hóa

Mẹo ở đây là dừng việc coi nền như "chỉ là một pixel khác" trước khi phép box-average chạy. Pre-pass tấm ảnh nguồn một lần, tách khỏi luồng trung bình hóa chính: với mỗi pixel, hoặc là bỏ nó về trong suốt (nó là nền), hoặc viết lại nó thành trắng thuần với tone được mã hóa vào kênh alpha, không phải RGB.

function preMaskToAlpha(sourceCanvas, { lumCeiling = 0.92, alphaFloor = 0.05 } = {}) {
  const ctx = sourceCanvas.getContext('2d');
  const { width, height } = sourceCanvas;
  const img = ctx.getImageData(0, 0, width, height);
  const d = img.data;

  for (let i = 0; i < d.length; i += 4) {
    const r = d[i], g = d[i + 1], b = d[i + 2], a = d[i + 3] / 255;
    const lum = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;

    // Nền: gần trắng, hoặc đã alpha thấp sẵn từ một bản export xóa nền.
    if (lum > lumCeiling || a < alphaFloor) {
      d[i] = d[i + 1] = d[i + 2] = 255;
      d[i + 3] = 0; // trong suốt hoàn toàn — không thể lem vào phép downscale
      continue;
    }

    // Chủ thể: mã hóa tone thành độ phủ (coverage), không phải màu.
    // Kéo giãn để dải tone còn lại lấp đầy 0..1 thay vì dồn cụm gần lumCeiling.
    const stretched = Math.min(1, (1 - lum) / (1 - lumCeiling * 0.4));
    d[i] = d[i + 1] = d[i + 2] = 255;
    d[i + 3] = Math.round(stretched * 255);
  }

  ctx.putImageData(img, 0, 0);
  return sourceCanvas;
}

Chạy hàm này một lần, trước bước 1 của pipeline box-average ở trên. Giờ mỗi pixel chủ thể là rgba(255,255,255, tone) và mỗi pixel nền là trong suốt hoàn toàn. Khi phép downscale trung bình hóa một cell, phép blend phần cứng của imageSmoothingEnabled sẽ tính theo coverage × alpha — một cell nửa nền nửa chủ thể sẽ ra đúng khoảng nửa alpha của chủ thể, thay vì trung bình về một giá trị RGB sáng. Không còn màu trắng nào để lem vào, vì "màu trắng" đó mang alpha bằng 0. Tone sống hoàn toàn trong alpha, nên phép compositing alpha tự làm đúng phép box-average bạn cần, miễn phí.

Nâng midtone trong shader, không phải ở ảnh nguồn

Pre-mask sửa được lỗi lem nền, nhưng bạn sẽ vẫn thấy hình dạng trông phẳng và xám thay vì rõ nét, đậm đà — đó là vấn đề số 1 ở trên, hiện tượng kéo về trung bình do box averaging. Cách sửa là áp một phép nâng midtone ngay sau khi sample, đúng chỗ bạn map tone sang kích thước dot hoặc opacity:

// Fragment shader (hoặc vertex shader điều khiển scale dot theo instance)
uniform sampler2D uToneMap; // texture cols x rows, tone nằm ở .a

void main() {
  float tone = texture2D(uToneMap, vGridUv).a;

  // Nâng midtone: pow < 1 đẩy các giá trị ở dải giữa lên gần 1.0
  // mà không cắt mất vùng đen thật (tone == 0 vẫn là 0) hay
  // vùng trắng thật (tone == 1 vẫn là 1). 0.5-0.7 là dải dùng được;
  // 0.6 là điểm khởi đầu tốt cho đa số ảnh.
  float lit = pow(tone, 0.6);

  float dotSize = mix(minDotSize, maxDotSize, lit);
  float dotAlpha = mix(minAlpha, lit, step(0.001, lit));

  // ... dùng lit để điều khiển gl_PointSize / scale instance và alpha fragment
}

Đường cong pow(tone, 0.6) giữ nguyên 0 ở 0 và 1 ở 1, nhưng uốn mọi giá trị ở giữa lên cao hơn — một tone thô 0.3 sau khi nâng sẽ thành khoảng 0.5. Đó chính là khác biệt giữa một hình dạng tách bạch rõ ràng khỏi trường dot xung quanh và một hình dạng tan biến vào nó. Bỏ qua bước này, dù ảnh đã pre-mask hoàn hảo thì vẫn trông thiếu sức sống, vì một tấm ảnh đã box-average gần như không bao giờ tự sinh ra tone ở gần các giá trị cực trị.

Tinh chỉnh bằng screenshot, ở đúng DPR thật

Hai bước sửa ở trên đã đúng về mặt toán, nhưng chặng cuối là chuyện thị giác: chụp screenshot thật của hiệu ứng ở đúng device pixel ratio bạn sẽ ship, không phải dpr: 1 trong một cửa sổ dev desktop bị scale. Những dot nhỏ hơn 2px render ở alpha thấp (dưới ~0.2) gần như vô hình ở dpr: 1 — chúng bị làm tròn về không hoặc anti-alias thành một lớp sương mà mắt vẫn đọc là "còn mờ", dù toán tone của bạn giờ đã đúng. Nếu thiết bị mục tiêu chạy ở dpr: 2 hay 3, kiểm tra luôn ở đó; một dot nhìn ổn ở 3x có thể biến mất trên màn 1x. Đẩy sàn minAlphaminDotSize lên cho tới khi tone tối nhất trong ảnh vẫn rõ ràng là một dot, chứ không phải chỉ là gợi ý mơ hồ về một dot, ở DPR thấp nhất bạn hỗ trợ.

FAQ

Vì sao hiệu ứng halftone canvas drawImage downscale của tôi lại bị mờ?

Vì một lần drawImage downscale là một phép box average đúng, nhưng tone đã box-average lại kéo về gần trung bình — rất ít cell chạm tới 0 hay 1 thật sự — và một phép map tuyến tính tone-sang-alpha hay tone-sang-size sẽ render dải midrange hẹp đó thành một màu xám phẳng. Áp phép nâng midtone (pow(tone, 0.6)) sau khi sample, trước khi điều khiển kích thước/opacity dot.

Vì sao mapping độ sáng ảnh sang particle grid vẫn quá mờ dù ảnh nguồn contrast cao?

Contrast cao của ảnh nguồn không sống sót qua phép downscale trừ khi độ phân giải grid đủ mịn so với kích thước chi tiết — box-averaging một vùng nhiều chi tiết vẫn kéo về gần trung bình. Kiểm tra lại pre-pass của bạn có kéo giãn tone vào alpha (không phải RGB) trước khi trung bình hóa hay chưa, rồi thêm phép nâng midtone; kết hợp cả hai sẽ khôi phục lại contrast mà phép trung bình thô đã phá.

Làm sao để chặn nền lem vào chủ thể khi sample ảnh cho particle WebGL?

Pre-mask ảnh nguồn một lần: mọi pixel gần trắng hoặc alpha thấp trở thành trong suốt hoàn toàn, và mọi pixel chủ thể được viết lại thành trắng-với-tone-nằm-trong-alpha. Vì nền mang alpha bằng 0, nó không đóng góp gì khi phép box-average blend một cell nằm ngay viền silhouette — không còn RGB sáng nào để lem vào nữa.

Thuật toán box-average sample ảnh đúng cho canvas là gì?

Vẽ ảnh nguồn ở đúng khung nó chiếm trên màn hình vào một canvas cỡ viewport, rồi làm một lần drawImage downscale vào canvas đích cols × rows với imageSmoothingEnabled (và imageSmoothingQuality = 'high') bật. Một lệnh vẽ tăng tốc phần cứng đó chính là phép trung bình — đừng bao giờ tự viết vòng lặp duyệt pixel thủ công cho từng cell, vừa chậm hơn vừa không cho kết quả tốt hơn.

Đường cong midtone lift trong shader tone mapping hoạt động thế nào?

pow(tone, 0.6) (hay bất kỳ số mũ nào dưới 1) giữ nguyên đen và trắng cố định ở 0 và 1, nhưng uốn đường cong sao cho các giá trị đầu vào ở dải giữa map ra giá trị đầu ra cao hơn — dải tone hẹp thường gặp sau khi trung bình hóa một tấm ảnh sẽ được kéo giãn trở lại thành contrast nhìn thấy được. Áp nó sau khi sample và trước khi dùng tone để điều khiển bất kỳ thuộc tính hiển thị nào (size, alpha, màu).


Cùng một bài học, hai cách sửa: drawImage downscale là phép box-average đúng, nhưng bạn phải pre-mask nền vào alpha để nó không thể lem, và nâng midtone bằng pow(tone, 0.6) sau khi sample để hình dạng thực sự tách khỏi trường dot xung quanh. Bỏ qua một trong hai là bạn quay lại đúng bóng ma mờ nhạt ban đầu.

Nếu bạn đang làm phần shader của một hiệu ứng kiểu này, bài hướng dẫn học GLSL shader cho người mới của tôi nói về pipeline uniform/varying mà kỹ thuật này đứng trên, và bài hiệu ứng displacement ảnh WebGL đi qua một pattern sample ảnh-sang-shader liên quan. Khi số lượng particle tăng lên, tối ưu hiệu năng Three.js là thứ đáng đọc tiếp theo. Tài liệu tham khảo: MDN — CanvasRenderingContext2D.drawImage()MDN — imageSmoothingEnabled.

Nếu bạn muốn một hiệu ứng kiểu này trong một hero section thật, trang dự án có vài ví dụ đã ship, và đây đúng là kiểu việc tôi nhận trong các dự án creative developer.