Home / Frontend / WebGL, Three.js và GLSL shader: từ cơ bản đến nâng cao
Frontend

WebGL, Three.js và GLSL shader: từ cơ bản đến nâng cao

Hướng dẫn WebGL, Three.js và GLSL shader từ cơ bản đến nâng cao: pipeline GPU, ShaderMaterial, uniform, varying, mix

Những hiệu ứng 3D mượt mà trên các website đoạt awards trông như ma thuật — còn tutorial WebGL thuần thì mở màn bằng gần 200 dòng boilerplate chỉ để vẽ một tam giác, đọc xong là bỏ.

Lối vào thực tế nhẹ nhàng hơn nhiều: để Three.js lo phần boilerplate, còn bạn tập trung học đúng hai thứ tạo ra ma thuật — vertex shader và fragment shader. Bài này đi từ pixel đầu tiên của WebGL đến shader nâng cao, mỗi bước một demo bấm chạy được ngay trong trình duyệt.

Nội dung dưới đây được viết lại theo curriculum khóa “WebGL and Shaders” của Matt DesLauriers trên Frontend Masters — prose và code hoàn toàn tự viết, API cập nhật theo Three.js r185 (khóa quay năm 2019, một số API đã đổi).

1. WebGL là gì và tại sao phải nhờ tới GPU

WebGL là một JavaScript API cho phép code của bạn nói chuyện trực tiếp với GPU thông qua thẻ <canvas>. CPU chạy vài nhân, xử lý tuần tự rất nhanh; GPU thì có hàng nghìn nhân nhỏ chạy song song. Một màn hình Full HD có hơn 2 triệu pixel — muốn tô màu tất cả 60 lần mỗi giây, bạn cần đúng kiểu phần cứng “làm cùng một phép tính trên rất nhiều dữ liệu một lúc” đó, và đó là GPU.

Điểm cốt lõi: GPU không chạy JavaScript. Nó chạy shader — chương trình nhỏ viết bằng ngôn ngữ GLSL, biên dịch rồi nạp thẳng vào GPU. Mỗi khung hình, dữ liệu hình học đi qua một chuỗi bước cố định gọi là render pipeline, và bạn chỉ được lập trình đúng hai bước trong đó.

View Mermaid diagram code
flowchart LR
    Geo["Geometry (vertices)"] --> VS["Vertex shader"]
    VS --> Raster["Rasterization"]
    Raster --> FS["Fragment shader"]
    FS --> Pixel["Pixel trên canvas"]

    style Geo fill:#0e2233,stroke:#4aa8ff,stroke-width:1.5px,color:#9ecbff
    style VS fill:#1a2c08,stroke:#c7ff50,stroke-width:1.5px,color:#c7ff50
    style Raster fill:#0e2233,stroke:#4aa8ff,stroke-width:1.5px,color:#9ecbff
    style FS fill:#1a2c08,stroke:#c7ff50,stroke-width:1.5px,color:#c7ff50
    style Pixel fill:#0e2233,stroke:#4aa8ff,stroke-width:1.5px,color:#9ecbff

Hai ô màu xanh lá là hai chỗ bạn viết code: vertex shader chạy một lần cho mỗi đỉnh (vertex) để quyết định nó nằm đâu trên màn hình, và fragment shader chạy một lần cho mỗi pixel để quyết định pixel đó màu gì. Rasterization ở giữa là bước cố định: GPU tự lấp đầy các pixel nằm bên trong tam giác.

Đây là pipeline phía GPU, không phải phía trình duyệt

Nếu bạn đã đọc Critical Rendering Path: Hành Trình Từ HTML Đến Pixels, lưu ý đây là một pipeline khác. Bài đó nói về việc trình duyệt biến HTML/CSS thành khung hình (DOM, CSSOM, Layout, Paint) — công việc chủ yếu chạy trên CPU. Còn pipeline ở đây bắt đầu từ lúc bạn đã có một canvas và muốn tự vẽ vào đó bằng GPU.

1.1. Một demo WebGL thuần tối giản

WebGL thuần khổ ở chỗ mọi thứ đều thủ công: tạo shader, biên dịch, link program, cấp buffer, trỏ attribute. Nhưng làm một lần cho biết mặt mũi thì rất đáng. Demo dưới đây vẽ một hình chữ nhật phủ kín canvas rồi để fragment shader tô mỗi pixel bằng một màu gradient chuyển động theo thời gian — bấm Run để xem GPU tính màu cho từng pixel:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
<canvas id="gl-canvas" width="320" height="180"></canvas>
<script>
  const canvas = document.getElementById("gl-canvas");
  const gl = canvas.getContext("webgl2") || canvas.getContext("webgl");

  // Vertex shader: đưa mỗi đỉnh thẳng ra tọa độ clip-space [-1, 1].
  const vertexSrc = `
    attribute vec2 position;
    void main() {
      gl_Position = vec4(position, 0.0, 1.0);
    }`;

  // Fragment shader: mỗi pixel một màu, đổi theo uTime.
  const fragmentSrc = `
    precision mediump float;
    uniform float uTime;
    uniform vec2 uResolution;
    void main() {
      vec2 uv = gl_FragCoord.xy / uResolution;
      vec3 color = 0.5 + 0.5 * cos(uTime + uv.xyx + vec3(0.0, 2.0, 4.0));
      gl_FragColor = vec4(color, 1.0);
    }`;

  function compile(type, src) {
    const shader = gl.createShader(type);
    gl.shaderSource(shader, src);
    gl.compileShader(shader);
    if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
      throw new Error(gl.getShaderInfoLog(shader));
    }
    return shader;
  }

  const program = gl.createProgram();
  gl.attachShader(program, compile(gl.VERTEX_SHADER, vertexSrc));
  gl.attachShader(program, compile(gl.FRAGMENT_SHADER, fragmentSrc));
  gl.linkProgram(program);
  gl.useProgram(program);

  // Hai tam giác tạo thành một hình chữ nhật phủ kín canvas.
  const buffer = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
  gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
    -1, -1, 1, -1, -1, 1,
    -1, 1, 1, -1, 1, 1,
  ]), gl.STATIC_DRAW);

  const loc = gl.getAttribLocation(program, "position");
  gl.enableVertexAttribArray(loc);
  gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);

  gl.uniform2f(gl.getUniformLocation(program, "uResolution"), canvas.width, canvas.height);
  const uTime = gl.getUniformLocation(program, "uTime");

  function render(t) {
    gl.uniform1f(uTime, t * 0.001);
    gl.drawArrays(gl.TRIANGLES, 0, 6);
    requestAnimationFrame(render);
  }
  requestAnimationFrame(render);
</script>

Đếm số dòng: gần như tất cả là thủ tục dựng khung, chỉ có đúng hai đoạn shader là phần “sáng tạo”. Ở các dự án thật, bạn không muốn viết lại đống boilerplate này mỗi lần. Đó chính là lý do Three.js tồn tại.

Lưu ý: WebGL2 là baseline hiện tại

Từ khoảng 2021 mọi trình duyệt lớn đều hỗ trợ WebGL2, nên demo trên xin webgl2 trước rồi mới fallback về webgl. Shader trong bài viết theo cú pháp GLSL ES 1.00 (dùng attribute, varying, gl_FragColor) vì nó ngắn và một context WebGL2 vẫn biên dịch được. WebGPU là API thế hệ sau (ngôn ngữ shader riêng tên WGSL); Three.js đã có WebGPURenderer nhưng WebGL vẫn là mặc định phổ dụng nhất tính đến r185.

2. Three.js: bộ khung tối giản

Three.js gói toàn bộ boilerplate ở mục 1 vào vài class. Cách setup hiện hành là dùng import map trỏ tên three về file module trên CDN, rồi import trong một <script type="module"> — kiểu <script src="..."> cũ đã lỗi thời. Bấm Run vào đoạn “hello world” dưới đây để xác nhận import map nạp được Three.js (nó in ra đúng số revision):

1
2
3
4
5
6
7
<script type="importmap">
  { "imports": { "three": "https://esm.sh/three@0.185.1" } }
</script>
<script type="module">
  import * as THREE from "three";
  document.body.textContent = "Three.js r" + THREE.REVISION + " đã sẵn sàng";
</script>

Bốn khái niệm bạn cần nắm để dựng bất kỳ scene nào:

  • Scene — cái “sân khấu” chứa mọi object.
  • PerspectiveCamera — góc nhìn, mô phỏng phối cảnh của mắt người (vật xa nhỏ hơn vật gần).
  • WebGLRenderer — thứ vẽ scene ra canvas mỗi khung hình.
  • Mesh = Geometry (hình dạng) + Material (bề mặt).

Three.js dùng hệ tọa độ right-handed, trục y hướng lên: x sang phải, y lên trên, z hướng về phía bạn. Vòng lặp vẽ dùng renderer.setAnimationLoop() — một wrapper quanh requestAnimationFrame tự dừng khi tab ẩn đi.

2.1. Mesh đầu tiên quay tròn

Demo dưới dựng một khối icosahedron (đa diện 20 mặt), gắn material phản ứng với ánh sáng, thêm hai nguồn sáng rồi quay nó trong vòng lặp. Bấm Run:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<canvas id="scene-basic" width="320" height="220"></canvas>
<script type="importmap">
  { "imports": { "three": "https://esm.sh/three@0.185.1" } }
</script>
<script type="module">
  import * as THREE from "three";

  const canvas = document.getElementById("scene-basic");
  const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
  renderer.setSize(320, 220, false);
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));

  const scene = new THREE.Scene();
  const camera = new THREE.PerspectiveCamera(45, 320 / 220, 0.1, 100);
  camera.position.z = 3;

  const geometry = new THREE.IcosahedronGeometry(1, 0);
  const material = new THREE.MeshStandardMaterial({ color: 0x4aa8ff, flatShading: true });
  const mesh = new THREE.Mesh(geometry, material);
  scene.add(mesh);

  scene.add(new THREE.AmbientLight(0xffffff, 0.4));
  const key = new THREE.DirectionalLight(0xffffff, 2.5);
  key.position.set(2, 3, 4);
  scene.add(key);

  renderer.setAnimationLoop((t) => {
    mesh.rotation.x = t * 0.0004;
    mesh.rotation.y = t * 0.0008;
    renderer.render(scene, camera);
  });
</script>

So với mục 1: không còn createShader, linkProgram, hay buffer thủ công. MeshStandardMaterial tự sinh shader tính ánh sáng cho bạn. Bạn chỉ mô tả muốn gì (một khối màu xanh, chiếu sáng, quay), Three.js lo phần làm thế nào.

3. Dựng một scene hoàn chỉnh

Một mesh quay tròn thì chưa thành cảnh. Ba thứ biến nó thành scene thật: material đúng, ánh sáng đúng, và cách tổ chức object theo nhóm.

3.1. Material và ánh sáng

MeshStandardMaterial là material dựa trên vật lý (PBR) — nó cần ánh sáng, không có đèn thì mesh đen thui. Ba loại đèn hay dùng, khác nhau ở cách phát sáng:

1
2
3
4
5
6
7
const ambient = new THREE.AmbientLight(0xffffff, 0.4);

const directional = new THREE.DirectionalLight(0xffffff, 2.5);
directional.position.set(2, 3, 4);

const point = new THREE.PointLight(0xffffff, 10, 100);
point.position.set(0, 2, 2);

Giải thích:

  • AmbientLight không có vị trí thật sự — nó chỉ cộng một mức sáng nền vào mọi bề mặt, tránh vùng tối hoàn toàn.
  • DirectionalLight dùng position để suy ra hướng tia sáng (từ vị trí đó nhắm về gốc tọa độ), độ sáng không đổi theo khoảng cách.
  • PointLight sáng mạnh ở gần và mờ dần ra xa, giống bóng đèn thật.

3.2. Texture và Group

Muốn bề mặt có hình ảnh thay vì màu trơn, bạn nạp một ảnh làm texture rồi gán vào map của material. TextureLoader trả texture ngay và tự cập nhật khi ảnh tải xong:

1
2
3
4
5
6
const texture = new THREE.TextureLoader().load("/textures/earth.jpg");
texture.colorSpace = THREE.SRGBColorSpace; // ảnh màu nên khai báo đúng color space
const planet = new THREE.Mesh(
  new THREE.SphereGeometry(1, 64, 64),
  new THREE.MeshStandardMaterial({ map: texture }),
);

Để animate nhiều object như một khối — ví dụ kinh điển: mặt trăng quay quanh hành tinh — bạn gom chúng vào một Group. Xoay group thì mọi thứ bên trong xoay theo, còn mỗi object vẫn tự quay quanh mình:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const system = new THREE.Group();
system.add(planet);

const moon = new THREE.Mesh(
  new THREE.SphereGeometry(0.25, 32, 32),
  new THREE.MeshStandardMaterial({ color: 0xaaaaaa }),
);
moon.position.x = 2; // đặt mặt trăng cách tâm 2 đơn vị
system.add(moon);
scene.add(system);

// Trong render loop: xoay cả hệ → mặt trăng quét quanh hành tinh.
system.rotation.y += 0.01;
planet.rotation.y += 0.005;

Đây là transform theo phân cấp (hierarchy): vị trí của moon được tính tương đối với system. Xoay cha thì con đi theo — nền tảng của mọi thứ từ hệ mặt trời tới cánh tay robot.

4. BufferGeometry: vertex ở tầng dữ liệu

Mọi geometry trong Three.js — Box, Sphere, Icosahedron — thực chất chỉ là một BufferGeometry: một danh sách các con số mô tả vị trí từng đỉnh. Hiểu tầng này giúp bạn tự tạo hình và sau đó viết shader tác động lên từng đỉnh.

Một attribute là một mảng dữ liệu, mỗi vertex một phần tử. Attribute quan trọng nhất là position, lưu trong một Float32Array phẳng: cứ ba số x, y, z là một đỉnh.

Dựng một BufferGeometry trần từ đúng ba đỉnh rồi thả Mesh vào một scene tối giản — bấm Run để thấy GPU dựng đúng tam giác từ mảng số phẳng đó:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
<canvas id="scene-tri" width="320" height="220"></canvas>
<script type="importmap">
  { "imports": { "three": "https://esm.sh/three@0.185.1" } }
</script>
<script type="module">
  import * as THREE from "three";

  const canvas = document.getElementById("scene-tri");
  const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
  renderer.setSize(320, 220, false);
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));

  const scene = new THREE.Scene();
  const camera = new THREE.PerspectiveCamera(45, 320 / 220, 0.1, 100);
  camera.position.z = 3;

  const geometry = new THREE.BufferGeometry();

  // Một tam giác: 3 đỉnh × 3 số = 9 phần tử.
  const positions = new Float32Array([
    0.0, 1.0, 0.0, // đỉnh trên
    -1.0, -1.0, 0.0, // đỉnh trái dưới
    1.0, -1.0, 0.0, // đỉnh phải dưới
  ]);

  // itemSize = 3 vì mỗi đỉnh gồm 3 con số (x, y, z).
  geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));

  const mesh = new THREE.Mesh(
    geometry,
    new THREE.MeshBasicMaterial({ color: 0xc7ff50, side: THREE.DoubleSide }),
  );
  scene.add(mesh);

  renderer.setAnimationLoop((t) => {
    mesh.rotation.y = Math.sin(t * 0.0008) * 0.6; // lắc nhẹ cho thấy đây là mesh thật
    renderer.render(scene, camera);
  });
</script>

Giải thích:

  • BufferAttribute(positions, 3) nói với GPU: đọc mảng theo từng nhóm 3 số, mỗi nhóm là tọa độ một đỉnh.
  • MeshBasicMaterial không cần ánh sáng (không tính chiếu sáng), tiện để kiểm tra hình dạng.

Lưu ý: class Geometry đã bị xóa

Nhiều tutorial cũ (gồm cả khóa gốc năm 2019) dùng class Geometry với .vertices.push(new THREE.Vector3(...)) cho dễ đọc. Class đó đã bị gỡ khỏi core từ r125 và xóa hẳn ở r141 — bản Three.js hiện tại chỉ còn BufferGeometry. Nếu bạn gặp code kiểu new THREE.Geometry(), đó là API đã chết; hãy chuyển sang BufferAttribute với Float32Array như trên.

5. Shader từ số 0

Giờ tới phần “ma thuật”. Một shader là chương trình viết bằng GLSL (OpenGL Shading Language), cú pháp giống C. Ở mục 1 bạn đã biết mỗi loại shader chạy ở đâu trong pipeline; giờ xem nhiệm vụ bắt buộc của từng loại:

  • Vertex shader phải gán gl_Position — vị trí cuối cùng của đỉnh trên màn hình.
  • Fragment shader phải gán màu cho pixel (gl_FragColor trong GLSL ES 1.00).

5.1. Kiểu dữ liệu và swizzle

GLSL strict hơn JavaScript nhiều: kiểu tĩnh, không tự ép kiểu, 1 (int) và 1.0 (float) là hai thứ khác nhau. Kiểu bạn dùng suốt là vector:

1
2
3
4
5
6
7
8
vec2 uv = vec2(0.5, 0.5);          // 2 thành phần
vec3 color = vec3(1.0, 0.0, 0.0);  // 3 thành phần — đỏ
vec4 rgba = vec4(color, 1.0);      // ghép vec3 + 1 float thành vec4

// Swizzle: đọc/ghép thành phần bằng .xyzw hoặc .rgba (cùng nghĩa).
float red = rgba.r;      // = rgba.x
vec3 bgr = color.bgr;    // đảo thứ tự kênh màu
vec2 flat = rgba.xy;     // lấy 2 thành phần đầu

Swizzle là thứ khiến code GLSL ngắn gọn đến khó tin: .xyx lấy ba thành phần theo thứ tự bạn muốn và trả về một vector mới. Kiểu tĩnh nghĩa là trộn int với float (1 + 0.5) sẽ không compile — luôn viết 1.0.

5.2. Vertex shader và fragment shader tối giản

Đây là cặp shader ngắn nhất có thể chạy được, viết cho ShaderMaterial của Three.js (Three.js tự cấp sẵn các uniform như projectionMatrix, modelViewMatrix và attribute position, uv, normal):

1
2
3
4
// VERTEX SHADER — đặt mỗi đỉnh vào đúng chỗ trên màn hình.
void main() {
  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
1
2
3
4
// FRAGMENT SHADER — tô mọi pixel một màu cam cố định.
void main() {
  gl_FragColor = vec4(1.0, 0.5, 0.2, 1.0);
}

Chuỗi projectionMatrix * modelViewMatrix * vec4(position, 1.0) là công thức chuẩn đưa một đỉnh từ không gian model ra không gian màn hình. Toán ma trận 3D đằng sau nó (model → view → projection) là một chủ đề riêng đủ dài; ở đây bạn chỉ cần dùng đúng công thức. Muốn đào sâu, tìm “MVP matrix” trong tài liệu WebGL.

6. ShaderMaterial: uniform, varying và UV

Shader cố định thì chưa làm được gì hay. Ba cơ chế biến nó thành động:

  • uniform — biến truyền từ JavaScript vào shader, giống nhau cho mọi vertex/pixel trong một lần vẽ (ví dụ: time, vị trí chuột).
  • varying — biến do vertex shader ghi ra và fragment shader đọc vào; GPU tự nội suy giá trị giữa các đỉnh.
  • UV — tọa độ 2D (0..1, 0..1) gắn sẵn trên mỗi vertex, cho biết pixel đang ở đâu trên bề mặt. Đây là “bản đồ” để vẽ hoa văn.

6.1. Truyền UV qua varying

Vertex shader ghi uv vào một varying vUv, fragment shader đọc vUv đã được nội suy để biết vị trí trên bề mặt và tô màu theo đó:

1
2
3
4
5
6
// VERTEX SHADER
varying vec2 vUv;
void main() {
  vUv = uv; // chuyển UV xuống cho fragment shader
  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
1
2
3
4
5
6
7
8
9
10
// FRAGMENT SHADER
uniform float uTime;
varying vec2 vUv;
void main() {
  // mix(a, b, t): trộn hai màu theo tỉ lệ t trong [0, 1].
  vec3 colorA = vec3(0.05, 0.3, 0.9);
  vec3 colorB = vec3(0.9, 0.2, 0.5);
  float t = 0.5 + 0.5 * sin(vUv.x * 6.2831 + uTime);
  gl_FragColor = vec4(mix(colorA, colorB, t), 1.0);
}

6.2. Demo: gradient động trên mặt cầu

Ghép hai shader trên vào một ShaderMaterial, khai báo uniform uTime rồi cập nhật nó mỗi khung hình. Kết quả là một dải màu chạy dọc theo UV của mặt cầu — bấm Run:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
<canvas id="scene-shader" width="320" height="220"></canvas>
<script type="importmap">
  { "imports": { "three": "https://esm.sh/three@0.185.1" } }
</script>
<script type="module">
  import * as THREE from "three";

  const canvas = document.getElementById("scene-shader");
  const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
  renderer.setSize(320, 220, false);
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));

  const scene = new THREE.Scene();
  const camera = new THREE.PerspectiveCamera(45, 320 / 220, 0.1, 100);
  camera.position.z = 3;

  const material = new THREE.ShaderMaterial({
    uniforms: { uTime: { value: 0 } },
    vertexShader: `
      varying vec2 vUv;
      void main() {
        vUv = uv;
        gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
      }`,
    fragmentShader: `
      uniform float uTime;
      varying vec2 vUv;
      void main() {
        vec3 colorA = vec3(0.05, 0.3, 0.9);
        vec3 colorB = vec3(0.9, 0.2, 0.5);
        float t = 0.5 + 0.5 * sin(vUv.x * 6.2831 + uTime);
        gl_FragColor = vec4(mix(colorA, colorB, t), 1.0);
      }`,
  });

  const mesh = new THREE.Mesh(new THREE.SphereGeometry(1, 64, 64), material);
  scene.add(mesh);

  renderer.setAnimationLoop((t) => {
    material.uniforms.uTime.value = t * 0.001;
    mesh.rotation.y = t * 0.0003;
    renderer.render(scene, camera);
  });
</script>

Chú ý ShaderMaterial không cần ánh sáng — màu do fragment shader tự quyết, không qua bước tính chiếu sáng. Bạn vừa tự viết toàn bộ phần “làm thế nào” mà ở mục 2 Three.js làm hộ.

7. Kỹ thuật trong fragment shader

Vài hàm GLSL dựng sẵn là nền tảng của gần như mọi hiệu ứng shader. Nắm ba hàm này là bạn tự làm được phần lớn thứ nhìn thấy trên shadertoy.

7.1. smoothstep để vẽ mask mượt

Muốn vẽ một chấm tròn, bạn tính khoảng cách từ pixel tới tâm rồi quyết định trong hay ngoài. Dùng ngưỡng cứng thì viền răng cưa; smoothstep(edge0, edge1, x) chuyển mượt từ 0 sang 1 trong khoảng giữa hai ngưỡng, tạo viền antialias:

1
2
3
4
5
6
7
8
uniform vec2 uResolution;
void main() {
  vec2 uv = gl_FragCoord.xy / uResolution;
  float dist = distance(uv, vec2(0.5)); // khoảng cách tới tâm canvas
  // Trong bán kính 0.28 → 1.0, ngoài 0.3 → 0.0, ở giữa chuyển mượt.
  float circle = 1.0 - smoothstep(0.28, 0.3, dist);
  gl_FragColor = vec4(vec3(circle), 1.0);
}

Đặt fragment shader này lên một quad phủ kín khung hình (canvas vuông cho chấm tròn đều) rồi bấm Run — viền chuyển mượt từ trắng sang đen, không hề răng cưa:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
<canvas id="fx-circle" width="260" height="260"></canvas>
<script type="importmap">
  { "imports": { "three": "https://esm.sh/three@0.185.1" } }
</script>
<script type="module">
  import * as THREE from "three";

  const canvas = document.getElementById("fx-circle");
  const renderer = new THREE.WebGLRenderer({ canvas });
  renderer.setSize(260, 260, false);

  const scene = new THREE.Scene();
  const camera = new THREE.Camera(); // quad phủ màn hình: không cần phối cảnh

  const uResolution = new THREE.Vector2();
  renderer.getDrawingBufferSize(uResolution);

  const material = new THREE.ShaderMaterial({
    uniforms: { uResolution: { value: uResolution } },
    vertexShader: `
      void main() { gl_Position = vec4(position, 1.0); }`,
    fragmentShader: `
      uniform vec2 uResolution;
      void main() {
        vec2 uv = gl_FragCoord.xy / uResolution;
        float dist = distance(uv, vec2(0.5)); // khoảng cách tới tâm canvas
        // Trong bán kính 0.28 → 1.0, ngoài 0.3 → 0.0, ở giữa chuyển mượt.
        float circle = 1.0 - smoothstep(0.28, 0.3, dist);
        gl_FragColor = vec4(vec3(circle), 1.0);
      }`,
  });

  const quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), material);
  quad.frustumCulled = false; // quad phủ clip-space, tắt culling cho chắc
  scene.add(quad);
  renderer.render(scene, camera);
</script>

7.2. noise để bề mặt sống động

sin/cos cho hoa văn đều đặn; muốn vẻ tự nhiên (mây, sóng, đá) bạn cần noise — hàm trả giá trị “ngẫu nhiên nhưng liền mạch”. GLSL không có noise dựng sẵn, nên mọi người dán một hàm quen thuộc vào shader. Phổ biến nhất là gói glsl-noise của Stefan Gustavson (simplex noise); dạng rút gọn hay gặp:

1
2
3
4
5
6
7
8
9
10
11
// Pseudo-random 2D → 1D: đủ dùng cho hiệu ứng hạt, nhiễu.
float random(vec2 st) {
  return fract(sin(dot(st, vec2(12.9898, 78.233))) * 43758.5453);
}

uniform vec2 uResolution;
void main() {
  vec2 uv = gl_FragCoord.xy / uResolution;
  float n = random(floor(uv * 20.0)); // chia lưới 20×20 ô nhiễu
  gl_FragColor = vec4(vec3(n), 1.0);
}

Bấm Run — mỗi ô trong lưới 20×20 nhận một mức xám ngẫu nhiên nhưng cố định; đây chính là kiểu nhiễu “giật ô” mà simplex noise sinh ra để tránh:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
<canvas id="fx-noise" width="260" height="260"></canvas>
<script type="importmap">
  { "imports": { "three": "https://esm.sh/three@0.185.1" } }
</script>
<script type="module">
  import * as THREE from "three";

  const canvas = document.getElementById("fx-noise");
  const renderer = new THREE.WebGLRenderer({ canvas });
  renderer.setSize(260, 260, false);

  const scene = new THREE.Scene();
  const camera = new THREE.Camera(); // quad phủ màn hình: không cần phối cảnh

  const uResolution = new THREE.Vector2();
  renderer.getDrawingBufferSize(uResolution);

  const material = new THREE.ShaderMaterial({
    uniforms: { uResolution: { value: uResolution } },
    vertexShader: `
      void main() { gl_Position = vec4(position, 1.0); }`,
    fragmentShader: `
      // Pseudo-random 2D → 1D: đủ dùng cho hiệu ứng hạt, nhiễu.
      float random(vec2 st) {
        return fract(sin(dot(st, vec2(12.9898, 78.233))) * 43758.5453);
      }

      uniform vec2 uResolution;
      void main() {
        vec2 uv = gl_FragCoord.xy / uResolution;
        float n = random(floor(uv * 20.0)); // chia lưới 20×20 ô nhiễu
        gl_FragColor = vec4(vec3(n), 1.0);
      }`,
  });

  const quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), material);
  quad.frustumCulled = false;
  scene.add(quad);
  renderer.render(scene, camera);
</script>

Với simplex noise thật (mượt, không giật ô), bạn import hàm snoise từ glsl-noise rồi cấp thêm uTime để nhiễu trôi theo thời gian.

7.3. Sóng sin trong vertex shader

Vertex shader không chỉ đặt đỉnh — nó có thể di chuyển đỉnh. Cộng một sóng sin theo position.xuTime vào position.y là mesh nhấp nhô như mặt nước:

1
2
3
4
5
6
uniform float uTime;
void main() {
  vec3 pos = position;
  pos.y += sin(pos.x * 4.0 + uTime) * 0.2; // đẩy đỉnh lên/xuống theo sóng
  gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
}

Đặt shader này lên một tấm lưới phẳng, bật wireframe cho dễ nhìn rồi bấm Run — cả mặt lưới gợn sóng chạy ngang theo thời gian:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
<canvas id="fx-wave" width="320" height="220"></canvas>
<script type="importmap">
  { "imports": { "three": "https://esm.sh/three@0.185.1" } }
</script>
<script type="module">
  import * as THREE from "three";

  const canvas = document.getElementById("fx-wave");
  const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
  renderer.setSize(320, 220, false);
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));

  const scene = new THREE.Scene();
  const camera = new THREE.PerspectiveCamera(45, 320 / 220, 0.1, 100);
  camera.position.set(0, 1.4, 2.2);
  camera.lookAt(0, 0, 0);

  const material = new THREE.ShaderMaterial({
    wireframe: true,
    uniforms: { uTime: { value: 0 } },
    vertexShader: `
      uniform float uTime;
      void main() {
        vec3 pos = position;
        pos.y += sin(pos.x * 4.0 + uTime) * 0.2; // đẩy đỉnh lên/xuống theo sóng
        gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
      }`,
    fragmentShader: `
      void main() { gl_FragColor = vec4(0.78, 1.0, 0.31, 1.0); }`,
  });

  // PlaneGeometry nằm trong mặt phẳng XY; xoay -90° quanh X để thành mặt phẳng ngang.
  const geometry = new THREE.PlaneGeometry(3, 3, 48, 48);
  geometry.rotateX(-Math.PI / 2);
  const mesh = new THREE.Mesh(geometry, material);
  scene.add(mesh);

  renderer.setAnimationLoop((t) => {
    material.uniforms.uTime.value = t * 0.002;
    renderer.render(scene, camera);
  });
</script>

Đây là lý do shader mạnh: cùng một phép tính chạy song song trên hàng nghìn đỉnh, mỗi đỉnh nhận một position.x khác nhau nên cả tấm lưới gợn sóng cùng lúc.

8. Nâng cao: rim lighting và custom attribute

Ba kỹ thuật đưa shader từ “demo” lên “trông xịn”. Phần này giới thiệu ý tưởng kèm demo bấm chạy được, không đi sâu lý thuyết.

8.1. Icosahedron cho vertex phân bố đều

Khi bạn muốn hiệu ứng chạy đều trên toàn bề mặt (nhiễu, dịch đỉnh), một SphereGeometry có vertex dồn về hai cực — hiệu ứng sẽ bị méo ở đó. IcosahedronGeometry(radius, detail) chia mặt cầu thành các tam giác gần đều nhau; tăng detail để có nhiều vertex hơn:

1
2
// detail = 20 → mặt cầu mịn với vertex phân bố rất đều, hợp cho shader dịch đỉnh.
const geometry = new THREE.IcosahedronGeometry(1, 20);

8.2. Rim lighting: viền sáng quanh vật thể

Rim lighting tô sáng phần rìa vật thể — nơi bề mặt gần như song song với hướng nhìn. Mẹo: tích vô hướng (dot) giữa pháp tuyến (normal) và hướng nhìn (viewDir) nhỏ khi bề mặt quay ngang, nên 1.0 - dot(...) lớn ở rìa:

1
2
3
4
5
6
7
8
9
// VERTEX SHADER — chuyển normal và vị trí (trong không gian view) xuống fragment.
varying vec3 vNormal;
varying vec3 vViewDir;
void main() {
  vNormal = normalize(normalMatrix * normal);
  vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
  vViewDir = normalize(-mvPosition.xyz); // hướng từ bề mặt về camera
  gl_Position = projectionMatrix * mvPosition;
}
1
2
3
4
5
6
7
8
9
10
// FRAGMENT SHADER — càng ra rìa, rim càng gần 1.0.
varying vec3 vNormal;
varying vec3 vViewDir;
void main() {
  float rim = 1.0 - max(dot(normalize(vNormal), normalize(vViewDir)), 0.0);
  rim = smoothstep(0.4, 1.0, rim); // siết cho viền gọn
  vec3 base = vec3(0.03, 0.07, 0.15);
  vec3 rimColor = vec3(0.4, 0.9, 1.0);
  gl_FragColor = vec4(base + rimColor * rim, 1.0);
}

Ghép cặp shader này vào một ShaderMaterial trên khối icosahedron ở mục 8.1 rồi bấm Run — một viền cyan sáng ôm lấy rìa khối cầu, đúng chỗ bề mặt quay ngang tầm nhìn:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
<canvas id="fx-rim" width="320" height="260"></canvas>
<script type="importmap">
  { "imports": { "three": "https://esm.sh/three@0.185.1" } }
</script>
<script type="module">
  import * as THREE from "three";

  const canvas = document.getElementById("fx-rim");
  const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
  renderer.setSize(320, 260, false);
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));

  const scene = new THREE.Scene();
  const camera = new THREE.PerspectiveCamera(45, 320 / 260, 0.1, 100);
  camera.position.z = 3;

  const material = new THREE.ShaderMaterial({
    vertexShader: `
      varying vec3 vNormal;
      varying vec3 vViewDir;
      void main() {
        vNormal = normalize(normalMatrix * normal);
        vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
        vViewDir = normalize(-mvPosition.xyz); // hướng từ bề mặt về camera
        gl_Position = projectionMatrix * mvPosition;
      }`,
    fragmentShader: `
      varying vec3 vNormal;
      varying vec3 vViewDir;
      void main() {
        float rim = 1.0 - max(dot(normalize(vNormal), normalize(vViewDir)), 0.0);
        rim = smoothstep(0.4, 1.0, rim); // siết cho viền gọn
        vec3 base = vec3(0.03, 0.07, 0.15);
        vec3 rimColor = vec3(0.4, 0.9, 1.0);
        gl_FragColor = vec4(base + rimColor * rim, 1.0);
      }`,
  });

  const mesh = new THREE.Mesh(new THREE.IcosahedronGeometry(1, 20), material);
  scene.add(mesh);

  renderer.setAnimationLoop((t) => {
    mesh.rotation.y = t * 0.0004;
    mesh.rotation.x = t * 0.0002;
    renderer.render(scene, camera);
  });
</script>

normalMatrix là uniform Three.js tự cấp để biến normal sang không gian view cho đúng. Hiệu ứng này là “gia vị” của vô số shader hào quang, khiên năng lượng, hành tinh có khí quyển.

8.3. Custom attribute: mỗi vertex một hành vi

Ngoài position/uv/normal, bạn có thể gắn attribute của riêng mình qua BufferGeometry — ví dụ một số ngẫu nhiên cho mỗi vertex để chúng chuyển động lệch pha nhau:

1
2
3
4
5
6
const count = geometry.attributes.position.count;
const offsets = new Float32Array(count);
for (let i = 0; i < count; i++) {
  offsets[i] = Math.random() * Math.PI * 2; // mỗi vertex một pha ngẫu nhiên
}
geometry.setAttribute("aOffset", new THREE.BufferAttribute(offsets, 1));

Khai báo attribute float aOffset; trong vertex shader là bạn đọc được giá trị đó cho từng đỉnh, rồi cộng vào pha của sóng sin ở mục 7.3 — mỗi đỉnh nhấp nhô theo nhịp riêng, cho chuyển động hữu cơ thay vì đều tăm tắp.

Đây chính là demo mục 7.3 cộng thêm aOffset vào pha. Bấm Run và so với sóng đều tăm tắp ở trên: giờ mỗi đỉnh lệch nhịp, mặt lưới nhấp nhô lộn xộn và sống động hơn hẳn:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
<canvas id="fx-organic" width="320" height="220"></canvas>
<script type="importmap">
  { "imports": { "three": "https://esm.sh/three@0.185.1" } }
</script>
<script type="module">
  import * as THREE from "three";

  const canvas = document.getElementById("fx-organic");
  const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
  renderer.setSize(320, 220, false);
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));

  const scene = new THREE.Scene();
  const camera = new THREE.PerspectiveCamera(45, 320 / 220, 0.1, 100);
  camera.position.set(0, 1.4, 2.2);
  camera.lookAt(0, 0, 0);

  const geometry = new THREE.PlaneGeometry(3, 3, 48, 48);
  geometry.rotateX(-Math.PI / 2);

  // Custom attribute: mỗi vertex một pha ngẫu nhiên.
  const count = geometry.attributes.position.count;
  const offsets = new Float32Array(count);
  for (let i = 0; i < count; i++) {
    offsets[i] = Math.random() * Math.PI * 2;
  }
  geometry.setAttribute("aOffset", new THREE.BufferAttribute(offsets, 1));

  const material = new THREE.ShaderMaterial({
    wireframe: true,
    uniforms: { uTime: { value: 0 } },
    vertexShader: `
      uniform float uTime;
      attribute float aOffset;
      void main() {
        vec3 pos = position;
        // cộng pha riêng của mỗi đỉnh → nhấp nhô lệch nhau, chuyển động hữu cơ
        pos.y += sin(pos.x * 4.0 + uTime + aOffset) * 0.2;
        gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
      }`,
    fragmentShader: `
      void main() { gl_FragColor = vec4(0.75, 0.55, 1.0, 1.0); }`,
  });

  const mesh = new THREE.Mesh(geometry, material);
  scene.add(mesh);

  renderer.setAnimationLoop((t) => {
    material.uniforms.uTime.value = t * 0.002;
    renderer.render(scene, camera);
  });
</script>

9. Đi tiếp từ đây

Bạn vừa đi hết chặng: từ pixel WebGL thuần, qua bộ khung Three.js, tới tự viết vertex và fragment shader với uniform, varying, mix, smoothstep, noise và rim lighting. Điểm mấu chốt để nhớ: Three.js lo boilerplate, còn hai shader là nơi bạn thật sự vẽ; kiểu dữ liệu GLSL strict hơn JavaScript nên luôn để ý 1.0 với 1; và mọi hiệu ứng phức tạp đều bắt đầu từ vài hàm dựng sẵn đơn giản.

Bước tiếp theo cụ thể: mở The Book of Shaders để luyện fragment shader từng bước, dạo Shadertoyglslsandbox để đọc shader người khác, rồi quay lại khóa gốc WebGL and Shaders của Matt DesLauriers trên Frontend Masters để xem tác giả dựng từng hiệu ứng trực tiếp. Hãy lấy demo mặt cầu ở mục 6, đổi vài dòng trong fragment shader, và xem GPU vẽ lại ngay — đó là cách học shader nhanh nhất.

← Previous
React Fiber Và Thuật Toán Diffing: Vì Sao State Bị Reset?
Table of Contents
  1. 1. WebGL là gì và tại sao phải nhờ tới GPU
    1. 1.1. Một demo WebGL thuần tối giản
  2. 2. Three.js: bộ khung tối giản
    1. 2.1. Mesh đầu tiên quay tròn
  3. 3. Dựng một scene hoàn chỉnh
    1. 3.1. Material và ánh sáng
    2. 3.2. Texture và Group
  4. 4. BufferGeometry: vertex ở tầng dữ liệu
  5. 5. Shader từ số 0
    1. 5.1. Kiểu dữ liệu và swizzle
    2. 5.2. Vertex shader và fragment shader tối giản
  6. 6. ShaderMaterial: uniform, varying và UV
    1. 6.1. Truyền UV qua varying
    2. 6.2. Demo: gradient động trên mặt cầu
  7. 7. Kỹ thuật trong fragment shader
    1. 7.1. smoothstep để vẽ mask mượt
    2. 7.2. noise để bề mặt sống động
    3. 7.3. Sóng sin trong vertex shader
  8. 8. Nâng cao: rim lighting và custom attribute
    1. 8.1. Icosahedron cho vertex phân bố đều
    2. 8.2. Rim lighting: viền sáng quanh vật thể
    3. 8.3. Custom attribute: mỗi vertex một hành vi
  9. 9. Đi tiếp từ đây