← Tất cả bài viết
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.

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óa | Hướng đi | Thay đổi theo | Dùng để |
|---|---|---|---|
uniform | CPU → cả hai shader | Mỗi lệnh vẽ (giống nhau cho mọi đỉnh/pixel) | Thời gian, vị trí chuột, texture, màu, progress |
attribute | CPU → vertex shader | Mỗi đỉnh | Position, UV, normal, dữ liệu riêng từng đỉnh |
varying | Vertex → fragment | Nội suy theo từng pixel | Chuyển UV/normal xuống để fragment shader dùng |
Vậy luồng đi là: bạn set uniform và attribute 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:
- 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.
- World space — sau model matrix: object nằm ở đâu trong cảnh.
- View space — sau view matrix: vị trí so với camera. three.js gộp model + view thành một
modelViewMatrix. - 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.
1là int,1.0là 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
floatvớivec3bằng+ở GLSL cũ cần cẩn thận, nhưng nhân thì được (vec3 * floatscale 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_Positionsai, hoặc alpha củagl_FragColorbằng 0. Tạm xuấtgl_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ặcmediump) ở đầu fragment shader khi viết GLSL thuần.ShaderMaterialcủ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 ở
devicePixelRatio3 đang chạy ~24 triệu lần mỗi frame. Giới hạndprtrê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
ifmà 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ênmix(),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.