上一篇我们在 同一个 shader-playground 工程里跑通了 ShaderMaterial、uniform 时间和 vUv 图案。现实项目里还有一大类需求:把位图或程序生成的图像贴到模型上,以及 不依赖贴图、用数学造出「像随机又像云」的灰度场。前者在 GLSL 里对应 sampler2D 与 texture2D 采样;后者对应 哈希函数 + 插值 构造的 value noise。
本篇继续在 shader-playground 下改 main.js。这样做的好处是,你可以在 Git 里用一次 git diff 看清「从纯色到贴图、再到噪声」到底多了哪些 uniform 和函数;也方便你把上一版的 main.js 复制一份改名为 main.step2.js 自己留档,而不必在磁盘上复制十份工程。
纹理这一节刻意不用外部 PNG,而是用 Canvas 画棋盘格再交给 CanvasTexture,避免教程还要附带资源文件、读者还要处理跨域。棋盘格边界清晰,一旦 UV 缩放或 RepeatWrapping 配错,肉眼立刻能发现。噪声这一节则完全在片元字符串里写 hash 和 noise,你会看到「只有 hash 时是块状随机,加上双线性插值后才变连续」——这对后面理解 simplex noise、Perlin noise 的文档非常关键。
注意 色彩空间:CanvasTexture 建好后设置 colorSpace = THREE.SRGBColorSpace,和 Three 默认的输出管线一致,否则灰棋盘可能在某些渲染路径里发灰或发闷。噪声输出的是线性灰度用于学习,暂不纠结显示伽马。
在 shader-playground 中继续
确认你仍在 shader-playground 目录,上一篇的 index.html 不用改,package.json 的 dev 脚本照旧。下面两个大段各给 一份完整 main.js,按顺序覆盖即可;若你想保留中间版本,可先复制当前 main.js 为 main.backup.js。
一、棋盘纹理与 UV 扰动
思路分两步:JavaScript 里用 2D 上下文画灰白格子,封装成 createCheckerTexture,返回 THREE.CanvasTexture;GLSL 里声明 uniform sampler2D uMap,在片元用 texture2D(uMap, uv) 取色。为了和上一篇衔接,保留 uTime,对采样用的 UV 做轻微正弦偏移,你会看到「贴图像液体一样扭」——很多故障艺术、热浪扭曲都是这个套路的变体。
RepeatWrapping 让 UV 超出 0~1 时重复铺砖;片元里把 vUv 乘以 3.0 等于把纹理在面上重复三次,格子更密。若你改成 ClampToEdgeWrapping,边缘会被拉长,对比两种 wrap 能加深对采样器的理解。
将 main.js 整体替换为:
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
function createCheckerTexture(size, cells) {
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
const step = size / cells;
for (let y = 0; y < cells; y++) {
for (let x = 0; x < cells; x++) {
const dark = (x + y) % 2 === 0;
ctx.fillStyle = dark ? '#303030' : '#d0d0d0';
ctx.fillRect(x * step, y * step, step, step);
}
}
const tex = new THREE.CanvasTexture(canvas);
tex.colorSpace = THREE.SRGBColorSpace;
tex.wrapS = THREE.RepeatWrapping;
tex.wrapT = THREE.RepeatWrapping;
return tex;
}
const scene = new THREE.Scene();
const width = window.innerWidth;
const height = window.innerHeight;
const camera = new THREE.PerspectiveCamera(60, width / height, 0.1, 100);
camera.position.set(0, 0, 2);
const map = createCheckerTexture(256, 8);
const vertexShader = `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
const fragmentShader = `
uniform sampler2D uMap;
uniform float uTime;
varying vec2 vUv;
void main() {
vec2 uv = vUv * 3.0;
uv.x += sin(uTime + vUv.y * 6.2831) * 0.03;
vec4 texel = texture2D(uMap, uv);
gl_FragColor = texel;
}
`;
const geometry = new THREE.PlaneGeometry(2, 2);
const material = new THREE.ShaderMaterial({
vertexShader,
fragmentShader,
uniforms: {
uMap: { value: map },
uTime: { value: 0 },
},
});
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(width, height);
renderer.setPixelRatio(window.devicePixelRatio);
document.body.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
const clock = new THREE.Clock();
function tick() {
requestAnimationFrame(tick);
material.uniforms.uTime.value = clock.getElapsedTime();
controls.update();
renderer.render(scene, camera);
}
tick();
window.addEventListener('resize', () => {
const w = window.innerWidth;
const h = window.innerHeight;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
});

二、value noise:从 hash 到连续灰度
程序化噪声的经典套路是:对每个格点用 hash 生成伪随机标量,再在格内对四个角做插值。hash 里用 sin、dot、fract 是常用写法,不是物理公式,只要 确定性好、分布够乱 即可。插值部分用 f * f * (3.0 - 2.0 * f) 相当于 smoothstep 的等价多项式,让边界不那么硬。
把 vUv 放大到 8.0 倍再送进 noise,细节更密;再加上 uTime 平移采样坐标,云团会漂移。你可以临时把 noise(p) 改成 hash(floor(p)),立刻得到块状马赛克,再改回来,对比比背定义更有效。
将 main.js 整体替换为:
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
const scene = new THREE.Scene();
const width = window.innerWidth;
const height = window.innerHeight;
const camera = new THREE.PerspectiveCamera(60, width / height, 0.1, 100);
camera.position.set(0, 0, 2);
const vertexShader = `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
const fragmentShader = `
uniform float uTime;
varying vec2 vUv;
float hash(vec2 p) {
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);
}
float noise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
float a = hash(i);
float b = hash(i + vec2(1.0, 0.0));
float c = hash(i + vec2(0.0, 1.0));
float d = hash(i + vec2(1.0, 1.0));
vec2 u = f * f * (3.0 - 2.0 * f);
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}
void main() {
vec2 p = vUv * 8.0 + vec2(uTime * 0.2, uTime * 0.15);
float n = noise(p);
vec3 col = vec3(n);
gl_FragColor = vec4(col, 1.0);
}
`;
const geometry = new THREE.PlaneGeometry(2, 2);
const material = new THREE.ShaderMaterial({
vertexShader,
fragmentShader,
uniforms: {
uTime: { value: 0 },
},
});
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(width, height);
renderer.setPixelRatio(window.devicePixelRatio);
document.body.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
const clock = new THREE.Clock();
function tick() {
requestAnimationFrame(tick);
material.uniforms.uTime.value = clock.getElapsedTime();
controls.update();
renderer.render(scene, camera);
}
tick();
window.addEventListener('resize', () => {
const w = window.innerWidth;
const h = window.innerHeight;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
});

案例代码上传了小册仓库
总结
- sampler2D 把 JS 侧的
Texture和 GLSL 的texture2D绑在一起,UV 变换决定采样位置,是贴图、法线、粗糙度通道的共同基础。 - value noise 教你「随机 + 插值 = 连续噪声」,后续分形、溶解、云,都是在此之上的层叠。
- 下一篇仍在该目录,把 顶点位移、法线光照、discard、Fresnel 分步跑通,从「画颜色」进入「动几何」和「视角相关效果」。