Skip to content

275. Shader 入门(二):纹理采样与程序化噪声

Published:

上一篇我们在 同一个 shader-playground 工程里跑通了 ShaderMaterial、uniform 时间和 vUv 图案。现实项目里还有一大类需求:把位图或程序生成的图像贴到模型上,以及 不依赖贴图、用数学造出「像随机又像云」的灰度场。前者在 GLSL 里对应 sampler2Dtexture2D 采样;后者对应 哈希函数 + 插值 构造的 value noise。

本篇继续在 shader-playground 下改 main.js。这样做的好处是,你可以在 Git 里用一次 git diff 看清「从纯色到贴图、再到噪声」到底多了哪些 uniform 和函数;也方便你把上一版的 main.js 复制一份改名为 main.step2.js 自己留档,而不必在磁盘上复制十份工程。

纹理这一节刻意不用外部 PNG,而是用 Canvas 画棋盘格再交给 CanvasTexture,避免教程还要附带资源文件、读者还要处理跨域。棋盘格边界清晰,一旦 UV 缩放或 RepeatWrapping 配错,肉眼立刻能发现。噪声这一节则完全在片元字符串里写 hashnoise,你会看到「只有 hash 时是块状随机,加上双线性插值后才变连续」——这对后面理解 simplex noise、Perlin noise 的文档非常关键。

注意 色彩空间CanvasTexture 建好后设置 colorSpace = THREE.SRGBColorSpace,和 Three 默认的输出管线一致,否则灰棋盘可能在某些渲染路径里发灰或发闷。噪声输出的是线性灰度用于学习,暂不纠结显示伽马。

在 shader-playground 中继续

确认你仍在 shader-playground 目录,上一篇的 index.html 不用改,package.jsondev 脚本照旧。下面两个大段各给 一份完整 main.js,按顺序覆盖即可;若你想保留中间版本,可先复制当前 main.jsmain.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);
});

2026-04-19 22.24.53.gif

二、value noise:从 hash 到连续灰度

程序化噪声的经典套路是:对每个格点用 hash 生成伪随机标量,再在格内对四个角做插值。hash 里用 sindotfract 是常用写法,不是物理公式,只要 确定性好、分布够乱 即可。插值部分用 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);
});

2026-04-19 22.25.27.gif

案例代码上传了小册仓库

总结

评论