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

[ Blog ]

1 tháng 7, 2026

12 phút đọc

Học GLSL Shader Cho Người Mới (Nhập Môn)

Hướng dẫn học GLSL shader cho người mới — vertex shader vs fragment shader, uniform, attribute, varying, hệ tọa độ và một ví dụ thực tế dán thẳng vào three.js.

GLSLShaderWebGLthree.jsReact Three Fiber
Học GLSL Shader Cho Người Mới (Nhập Môn)

Mọi hiệu ứng ảnh tan chảy, khối lỏng, hay gradient "ảo diệu" bạn thấy trên các website đoạt giải Awwwards, bên dưới đều là vài chục dòng GLSL. Nếu bạn viết được JavaScript, bạn học được shader — nhưng mô hình tư duy thật sự khác, và hầu hết tài liệu lại bỏ qua đúng phần giúp bạn thông suốt. Bài học GLSL shader cho người mới này đưa cho bạn mô hình đó: GPU thực chất đang làm gì, khác biệt giữa vertex và fragment shader, dữ liệu chảy vào qua uniform/attribute/varying ra sao, và một ví dụ thực tế dán thẳng vào three.js được ngay hôm nay.

Tôi viết shader cho sản phẩm thật mỗi ngày — toàn bộ hiệu ứng displacement và xử lý ảnh mà website này được biết đến đều là GLSL — và mục tiêu ở đây là đưa bạn từ "copy shader mà không hiểu gì" tới "đọc và tự sửa được một shader". Không lạc đề lý thuyết đồ họa, chỉ những phần thật sự cần cho web.

Shader là gì (và vì sao lúc đầu thấy lạ)

Shader là một chương trình nhỏ chạy trên GPU, song song, hàng nghìn tới hàng triệu lần mỗi frame. Chính tính song song đó khiến nó cảm giác xa lạ. JavaScript của bạn chạy một lần, từ trên xuống dưới, trên một luồng. Còn fragment shader chạy một lần cho mỗi pixel, tất cả cùng lúc, và không hề biết các pixel hàng xóm đang làm gì. Bạn không lặp qua từng pixel; bạn viết logic cho một pixel rồi GPU nhân nó ra.

Mỗi lệnh vẽ WebGL luôn có hai shader, chạy nối tiếp:

  • Vertex shader — chạy một lần cho mỗi đỉnh (điểm góc) của hình học. Việc của nó là quyết định vị trí trên màn hình của mỗi đỉnh. Bắt buộc phải ghi gl_Position.
  • Fragment shader (hay pixel shader) — chạy một lần cho mỗi pixel mà hình học phủ lên. Việc của nó là quyết định màu của pixel đó. Trong WebGL1/GLSL ES 1.00 nó ghi vào gl_FragColor.

Giữa hai bước, GPU "rasterise": lấy ba đỉnh đã được định vị của một tam giác, tính xem pixel nào nằm trong, rồi chạy fragment shader cho từng pixel. Mọi thứ vertex shader chuyển xuống sẽ được nội suy (interpolate) mượt mà dọc tam giác — đó chính là varying, ta sẽ nói tới ngay.

GLSL (OpenGL Shading Language) là ngôn ngữ kiểu C mà cả hai shader dùng. Nó định kiểu chặt, không có console.log, và sẽ không tự ép 1 thành 1.0 cho bạn. Ba điều đó gây ra khoảng 80% nỗi đau của người mới.

Ba cách dữ liệu đi vào shader

Đây là bảng quan trọng nhất trong thế giới shader. Gần như mọi câu hỏi "sao shader của tôi đen thui" đều xuất phát từ nhầm lẫn ba thứ này:

Từ khóaHướng điThay đổi theoDùng để
uniformCPU → cả hai shaderMỗi lệnh vẽ (giống nhau cho mọi đỉnh/pixel)Thời gian, vị trí chuột, texture, màu, progress
attributeCPU → vertex shaderMỗi đỉnhPosition, UV, normal, dữ liệu riêng từng đỉnh
varyingVertex → fragmentNội suy theo từng pixelChuyển UV/normal xuống để fragment shader dùng

Vậy luồng đi là: bạn set uniformattribute từ JavaScript, vertex shader đọc chúng và phát ra varying, rồi fragment shader đọc các varying đã nội suy để tính màu. Khắc cái pipeline này vào đầu là shader hết còn huyền bí.

Trong WebGL2 / GLSL ES 3.00 các từ khóa đổi — attribute thành in, varying thành in/out, và bạn tự khai báo out vec4 thay cho gl_FragColor — nhưng khái niệm hoàn toàn giống nhau. three.js mặc định vẫn dùng cú pháp GLSL ES 1.00 trong ShaderMaterial, nên đó là thứ tôi viết ở đây.

Shader đầu tiên: một màu phẳng

Đây là cặp shader tối giản nhất. Vertex shader định vị mỗi đỉnh; fragment shader tô mọi pixel màu cam.

// Vertex shader
void main() {
  // projectionMatrix và modelViewMatrix do three.js cung cấp sẵn
  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
// Fragment shader
void main() {
  gl_FragColor = vec4(1.0, 0.5, 0.0, 1.0); // r, g, b, a — đều 0..1
}

Hai điều cần để ý ngay. Thứ nhất, position, projectionMatrix, modelViewMatrix xuất hiện như từ trên trời — ShaderMaterial của three.js tự "tiêm" sẵn các attribute và uniform dựng sẵn này (còn RawShaderMaterial thì không; bạn phải tự khai báo). Thứ hai, mọi con số đều là float có dấu chấm thập phân. Viết vec4(1, 0.5, 0, 1) là shader không biên dịch được, vì GLSL không tự nâng số nguyên 1 thành float. Đây là lỗi bạn sẽ gặp nhiều nhất.

Hệ tọa độ: phần không ai chịu giải thích

Vertex shader thực chất là một chuỗi phép biến đổi giữa các hệ tọa độ, và hiểu nó sẽ làm sáng tỏ dòng gl_Position:

  1. Object/local space — vị trí đỉnh như định nghĩa trong hình học, lấy gốc tọa độ của chính mô hình.
  2. World space — sau model matrix: object nằm ở đâu trong cảnh.
  3. View space — sau view matrix: vị trí so với camera. three.js gộp model + view thành một modelViewMatrix.
  4. Clip space — sau projection matrix: phép "ép" phối cảnh, xuất ra gl_Position.

Vậy projectionMatrix * modelViewMatrix * vec4(position, 1.0) đọc từ phải sang trái: lấy đỉnh local, đưa vào không gian camera, rồi áp phối cảnh. Khi muốn biến dạng hình học — sóng, gợn, phình — bạn sửa position trước phép nhân đó, trong object space. Đó là đòn bẩy cho hiệu ứng dịch chuyển đỉnh (vertex displacement).

Trong fragment shader, tọa độ bạn quan tâm thường là UV: ánh xạ 0–1 trên bề mặt, với (0,0) là một góc và (1,1) là góc đối diện. Bạn chuyển nó xuống bằng một varying:

// Vertex shader
varying vec2 vUv;
void main() {
  vUv = uv; // attribute 'uv' do three.js cung cấp
  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
// Fragment shader
varying vec2 vUv;
void main() {
  gl_FragColor = vec4(vUv, 0.0, 1.0); // x→đỏ, y→xanh lá: một gradient
}

Cái gradient đó — đen sang đỏ theo chiều ngang, đen sang xanh lá theo chiều dọc — là "hello world" của fragment shader. Tạo được nó nghĩa là pipeline varying của bạn đã thông suốt từ đầu đến cuối.

Ví dụ thực tế: gradient động

Cho nó chuyển động nào. Ta thêm uniform uTime (điều khiển từ JavaScript) và dùng lượng giác trên UV để được gradient "sống". Đây đúng là khuôn mẫu sẽ phát triển lên thành các hiệu ứng displacement và noise bạn thấy trên các site cao cấp.

// Fragment shader
uniform float uTime;
varying vec2 vUv;

void main() {
  // Tạo sóng chạy ngang trục x
  float wave = sin(vUv.x * 10.0 + uTime) * 0.5 + 0.5; // remap -1..1 về 0..1
  vec3 colorA = vec3(0.05, 0.0, 0.3);  // chàm đậm
  vec3 colorB = vec3(1.0, 0.85, 0.0);  // vàng thương hiệu
  vec3 color = mix(colorA, colorB, wave * vUv.y);
  gl_FragColor = vec4(color, 1.0);
}

Ba hàm dựng sẵn của GLSL đang gánh phần nặng và bạn sẽ dùng chúng liên tục: sin() để dao động, mix(a, b, t) để nội suy tuyến tính giữa hai giá trị, và mẹo * 0.5 + 0.5 để đổi khoảng -1..1 thành 0..1. Nắm chắc ba thứ đó là dựng được vô số hiệu ứng.

Giờ ráp lại trong React Three Fiber — cách gọn nhất để truyền uniform và chạy vòng lặp frame:

import { useRef } from "react";
import { Canvas, useFrame } from "@react-three/fiber";
import * as THREE from "three";

const vertex = /* glsl */ `
  varying vec2 vUv;
  void main() {
    vUv = uv;
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
  }
`;

const fragment = /* glsl */ `
  uniform float uTime;
  varying vec2 vUv;
  void main() {
    float wave = sin(vUv.x * 10.0 + uTime) * 0.5 + 0.5;
    vec3 color = mix(vec3(0.05, 0.0, 0.3), vec3(1.0, 0.85, 0.0), wave * vUv.y);
    gl_FragColor = vec4(color, 1.0);
  }
`;

function GradientPlane() {
  const mat = useRef<THREE.ShaderMaterial>(null);
  useFrame((_, delta) => {
    if (mat.current) mat.current.uniforms.uTime.value += delta;
  });

  return (
    <mesh>
      <planeGeometry args={[2, 2, 1, 1]} />
      <shaderMaterial
        ref={mat}
        vertexShader={vertex}
        fragmentShader={fragment}
        uniforms={{ uTime: { value: 0 } }}
      />
    </mesh>
  );
}

export default function Scene() {
  return (
    <Canvas>
      <GradientPlane />
    </Canvas>
  );
}

Dòng mat.current.uniforms.uTime.value += delta chính là cầu nối giữa hai thế giới: JavaScript chạy đồng hồ, GPU chạy phép toán. Đổi fragment shader thành một cái lấy mẫu texture rồi dịch UV, là bạn đã dựng nên hiệu ứng displacement ảnh WebGL khi hover — ví dụ gradient và hiệu ứng displacement là cùng một cỗ máy với phần thân fragment khác đi mà thôi.

Những cái bẫy ngốn hàng giờ của người mới

Đây là những lỗi tôi vẫn thấy cả dev JS lâu năm dính ngay ngày đầu:

  • Số nguyên vs float. 1 là int, 1.0 là float, GLSL không trộn được. float x = 1; lỗi. sin(5) lỗi. Chấm thập phân cho tất cả.
  • Phép toán vector không "tự nhiên" như bạn tưởng. Cộng float với vec3 bằng + ở GLSL cũ cần cẩn thận, nhưng nhân thì được (vec3 * float scale từng thành phần). Đọc kỹ kiểu của mọi toán hạng.
  • Màn hình đen là lỗi mặc định. Nếu shader biên dịch được mà chẳng thấy gì: hình học có thể nằm sau camera, phép gl_Position sai, hoặc alpha của gl_FragColor bằng 0. Tạm xuất gl_FragColor = vec4(1.0); để xác nhận fragment shader có chạy hay không.
  • Lỗi biên dịch âm thầm. Shader gõ sai thường chỉ ra màu đen. Mở console trình duyệt — three.js in lỗi của trình biên dịch GLSL kèm số dòng. Hãy đọc; nó rất chính xác.
  • Precision trên mobile. Khai báo precision highp float; (hoặc mediump) ở đầu fragment shader khi viết GLSL thuần. ShaderMaterial của three.js thêm sẵn cho bạn, nhưng WebGL thuần và một số GPU Android cần khai báo rõ, không thì sinh nhiễu.

Lưu ý hiệu năng từ thực chiến

Shader vốn rẻ — đó là mục đích của nó — nhưng không miễn phí, và fragment shader là nơi chi phí ẩn vì nó chạy theo từng pixel:

  • Chi phí fragment tỉ lệ với số pixel màn hình, không phải số đỉnh. Một shader phủ toàn màn 4K ở devicePixelRatio 3 đang chạy ~24 triệu lần mỗi frame. Giới hạn dpr trên canvas (dpr={[1, 2]} trong R3F) — đây là cú thắng lớn nhất trên điện thoại.
  • Rẽ nhánh thì đắt. GPU chạy pixel theo nhóm đồng bộ; một if mà các pixel hàng xóm đi hai hướng khác nhau buộc GPU chạy cả hai nhánh. Ưu tiên mix(), step(), smoothstep() thay cho điều kiện khi có thể.
  • Đẩy việc lên vertex shader khi được. Một plane có 4 đỉnh nhưng cả triệu pixel — thứ gì tính được mỗi đỉnh rồi chuyền xuống bằng varying thì rẻ hơn hẳn so với tính mỗi fragment.
  • Đừng render khi không có gì đổi. Shader tĩnh không cần vòng lặp render; dùng frameloop="demand" và invalidate khi tương tác. Cùng kỷ luật giữ cho cảnh three.js được tối ưu cũng áp dụng cho một plane shader đơn lẻ.

Câu hỏi thường gặp

Có cần giỏi toán đồ họa mới viết được shader không?

Không — ít nhất là để bắt đầu. Vector, mix, sin, clamp đã đưa bạn đi rất xa, phần còn lại sẽ thấm dần qua việc đọc và chỉnh sửa. Lượng giác giúp ích cho sóng và chuyển động tròn; đại số tuyến tính (ma trận) chỉ quan trọng khi đào sâu vào biến đổi đỉnh, mà phần lớn three.js đã lo giúp.

GLSL và WebGL khác nhau thế nào?

WebGL là API của trình duyệt cho phép JavaScript nói chuyện với GPU và phát lệnh vẽ. GLSL là ngôn ngữ mà bản thân shader được viết. Bạn dùng WebGL (hoặc bọc qua three.js) để biên dịch và chạy GLSL. Chúng là hai lớp của cùng một tầng công nghệ, không phải lựa chọn thay thế nhau.

Nên học WebGL thuần hay dùng three.js / React Three Fiber?

Hãy học khái niệm shader trên three.js hoặc React Three Fiber trước — chúng dẹp 200 dòng boilerplate về context, buffer, program để bạn tập trung vào GLSL thật sự. Chỉ hạ xuống WebGL thuần về sau khi cần kiểm soát tinh vi hoặc ship một widget nhỏ không phụ thuộc. GLSL bạn viết là như nhau ở cả hai đường.

Luyện GLSL ở đâu ngoài dự án thật?

The Book of Shaders là tài liệu tương tác kinh điển cho fragment shader, còn ShaderToy cho bạn đọc và fork hàng nghìn ví dụ chạy trực tiếp. Cả hai chạy GLSL ngay trong trình duyệt với phản hồi tức thì — lý tưởng để xây cảm giác trước khi ráp vào cảnh thật.


Shader là kỹ năng phân biệt một website "đẹp đẹp" với một website khiến người ta phải chụp màn hình. Một khi mô hình vertex/fragment/uniform đã thông, những hiệu ứng displacement, trường hạt, gradient sinh thủ trên các site đoạt giải thôi trông như phép thuật và bắt đầu trông như thứ bạn dựng được. Nếu bạn muốn loại tay nghề GPU này được dựng vào sản phẩm của mình, xem cách tôi làm việc với khách hàng và studio hoặc xem các dự án nó đã ship.