three.js DotScreenPass 完整上手:五分钟做出半色调网点复古效果
【免费下载链接】three.jsJavaScript 3D Library.项目地址: https://gitcode.com/GitHub_Trending/th/three.js
DotScreenPass 是 three.js 后处理通道体系里负责"画面去色"的那位成员:它把最终渲染结果压成一张报纸印刷式的半色调网点点阵图。把报纸照片放大看,画面其实由密集的圆点排布而成,亮的地方点更小、暗的地方点更粗——这个 pass 就是为任意 3D 画面重建这种"网点化"观感。
它工作在管线的哪个位置?三维场景照常渲染进渲染目标之后,DotScreenPass 把上一通道的输出当纹理读进来,用一张全屏片元着色器逐像素重写,属于纯屏幕空间效果,完全不碰几何体和材质。适合用它的项目:复古街机风 UI、音乐可视化、老电视/老电影氛围、需要"信号劣化"质感的加载页与艺术化展示屏。
截图来自官方示例 webgl_postprocessing_advanced.html,四个象限分别是不同后处理通道的输出,右下角象限的半色调网点正是 DotScreenPass 的最终形态。
⚡ 五分钟接入:three.js 后处理的最短通道链
引入 addon、加一行addPass,点阵就能看到。
DotScreenPass 属于 addons(附加模块),不在核心包里,必须显式 import:
import * as THREE from 'three'; import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js'; import { RenderPass } from 'three/addons/postprocessing/RenderPass.js'; import { DotScreenPass } from 'three/addons/postprocessing/DotScreenPass.js';然后拼出最短管线:
const composer = new EffectComposer( renderer ); composer.addPass( new RenderPass( scene, camera ) ); const halftone = new DotScreenPass( new THREE.Vector2( 0, 0 ), 0.5, 0.8 ); composer.addPass( halftone ); function tick() { composer.render(); requestAnimationFrame( tick ); } tick();为什么习惯把它放在通道链末尾?因为链上最后一个通道会被 composer 自动标记为"直接渲染到屏幕",而这个 pass 的输入就是"上一通道递过来的画面"——放在末尾,它处理的才是最完整的最终图像。如果还想在它之上叠别的滤镜,把后者加在它后面即可。
🧭 DotScreenPass 参数怎么调:center、angle、scale 一次讲清
三个构造参数全部可选,默认值由着色器自带,构造函数只覆盖你传入的那几个。
new DotScreenPass( center, angle, scale )| 参数 | 类型 | 作用 | 默认值 | 调整后的视觉变化 | 推荐值 |
|---|---|---|---|---|---|
center | Vector2 | 点阵相位原点,网点围绕它排布 | (0.5, 0.5) | 移动它相当于让整片网点"平移"并围绕新点对称 | (0, 0)或(0.5, 0.5) |
angle | number | 网点网格的旋转角,单位弧度 | 1.57(约 90°) | 调大网格肉眼可见地转动;给0.5得到轻微倾斜 | 0.5轻微斜排,1.57保持正交 |
scale | number | 点阵密度系数 | 1.0 | 反直觉:值越大点越小越密,调小则颗粒变粗 | 0.6–1.0经典半色调,0.2–0.4粗点阵 |
其中scale最容易踩直觉坑。很多人下意识认为"scale 大 = 点大",但看 DotScreenPass.js 的源码注释:"A higher value means smaller dots"。原理上它乘在坐标上,相当于把整片点阵捏扁或放大:值越大,坐标被压缩得越狠,采样网格就越密,点看起来越小。想要粗颗粒的老 CRT 感,往小了调才对。
🌀 运行时动态效果:改 uniforms 网点立刻转起来
pass.uniforms与material是同一份引用,帧循环里改完的值下一帧渲染就被着色器读到,零同步成本。
构造函数里this.uniforms被原封不动地传给了ShaderMaterial(见 DotScreenPass.js 第 57 行),所以这两个属性指向同一个 uniform 对象。运行时调参就是官方注释里给出的那种写法:
halftone.uniforms.center.value.set( 0.5, 0.5 ); halftone.uniforms.angle.value = 0; halftone.uniforms.scale.value = 0.6;把它放进帧循环,就能得到缓慢旋转、呼吸脉动的网点动画:
function tick() { halftone.uniforms.angle.value += 0.004; // 网点慢速旋转 halftone.uniforms.scale.value = 0.8 + 0.4 * Math.sin( performance.now() * 0.0012 ); // 呼吸式脉动 composer.render(); requestAnimationFrame( tick ); }不需要重建 pass,也不需要碰材质对象,逐帧写值就是标准用法。
🔍 半色调是怎么生成的:片元着色器三步拆解
片元着色器的核心逻辑就三步:取亮度 → 坐标旋转缩放 → 周期函数调制,全部发生在每个像素里。
亮度提取:把输入像素的 RGB 三通道取平均average,作为该点的灰度亮度。
坐标变换:vUv * tSize把归一化坐标换算到像素空间(tSize是渲染缓冲实际宽高),减去center以它为新原点,再用angle的正余弦做二维旋转,最后整体乘scale压缩或拉伸。这正是angle转网点方向、scale改点阵密度的数学来源。
周期调制:变换后的两个坐标各自取sin再相乘,得到在 ±1 之间振荡的周期值(乘 4 放大振幅),叠加到亮度基底average * 10 - 5上,最终 RGB 同值输出、原 alpha 透传。
关键 GLSL 节选(DotScreenShader.js 源码注释标明算法借鉴自 glfx.js 项目的 sepia 着色器):
float pattern() { float s = sin( angle ), c = cos( angle ); vec2 tex = vUv * tSize - center; vec2 point = vec2( c * tex.x - s * tex.y, s * tex.x + c * tex.y ) * scale; return ( sin( point.x ) * sin( point.y ) ) * 4.0; } void main() { vec4 color = texture2D( tDiffuse, vUv ); float average = ( color.r + color.g + color.b ) / 3.0; gl_FragColor = vec4( vec3( average * 10.0 - 5.0 + pattern() ), color.a ); }两个值得注意的细节:一是gl_FragColor的三个通道写入同一个值,所以最终画面本质是"灰度 + 网点",原图的彩色信息在这里被压掉了——想调色(sepia、滤镜色)要放在这个 pass 之前或之后做;二是pattern()的结构足够简单,想改成 45° 新闻纸斜排或 CMYK 分色网点,照着它重写片元着色器即可。
🧩 与其他滤镜的组合:一条"伽马→网点→渐晕"后处理链
通道顺序决定每个滤镜吃的是哪一帧画面,复古链的常用排法是:场景渲染 → 伽马校正 → 网点 → 渐晕。
import { ShaderPass } from 'three/addons/postprocessing/ShaderPass.js'; import { GammaCorrectionShader } from 'three/addons/shaders/GammaCorrectionShader.js'; import { VignetteShader } from 'three/addons/shaders/VignetteShader.js'; composer.addPass( new RenderPass( scene, camera ) ); composer.addPass( new ShaderPass( GammaCorrectionShader ) ); const halftone = new DotScreenPass( new THREE.Vector2( 0.5, 0.5 ), 1.2, 0.9 ); composer.addPass( halftone ); const vignette = new ShaderPass( VignetteShader ); vignette.uniforms.offset.value = 1.4; vignette.uniforms.darkness.value = 1.1; composer.addPass( vignette );顺序的意义:伽马校正放在网点前面,是为了让"取 RGB 均值为亮度"这一步算在最终的色调上,而不是在线性空间里算错一次;渐晕放在网点后面,把四角压暗,恰好遮住边缘最显粗的网点,整幅画面更像一张"投射出来的老照片"。官方示例里这个滤镜还常和FilmPass(胶片颗粒)、sepia 怀旧色调、Bloom 同屏出现,组合出老电影放映的质感。
🛠 工程注意事项:导出入口、TSL 差异与 dispose 时机
- 属于 addons,不在核心包:统一导出见 Addons.js,从核心
three入口 import 是找不到的,必须显式引入 addon 路径。 - 只服务于 WebGLRenderer 管线:Pass.js 的模块注释明确写着本模块仅适用于 WebGLRenderer 的后处理。如果项目走 WebGPU + TSL(Three Shading Language),对应的是另一套节点实现 DotScreenNode.js,用法完全不同,别把两边的写法混着抄。
- dispose() 别忘:页面卸载或重建通道链时调用
halftone.dispose(),它会依次释放材质与全屏四边形几何体,漏掉就是 GPU 资源泄漏。 - 别以为
tSize恒为 256:render()每帧都会用readBuffer的真实宽高覆盖它(readBuffer是上一通道的输出纹理缓冲),所以同样的scale在低分辨率目标上点很明显、放到 4K 画面上就细得几乎看不见,调参前先确认渲染目标分辨率。 - 输出是灰度点阵:如前所述 RGB 被压成亮度均值,需要彩色网点必须自己改着色器,不要指望调 uniform 就能出彩色。
❓ DotScreenPass 常见问题
可以只传 angle、省掉 center 吗?
可以,三个参数全部可选。构造函数只为传入的值写 uniform,省略的沿用着色器初始值:center(0.5, 0.5)、angle1.57、scale1.0。
为什么点细得像噪点,画面接近纯白?
多半是scale与分辨率共同作用的结果:tSize每帧被真实渲染目标尺寸覆盖,高分辨率下同样的密度系数会算出极细的网点。把scale调小,或先在低分辨率渲染目标上确定观感再迁移,通常就解决了。
每帧写 uniforms 会有性能开销吗?
没有。uniforms 与 material 共享同一份引用,值在渲染时被重新读取,逐帧写值是标准动态调参方式,开销可以忽略。
想改成彩色网点(比如 CMYK 分色)怎么做?
改main()的最后一行:不再让三个通道取同一个值,而是按average或原始 RGB 分量做通道选择;或者直接照 DotScreenShader.js 的结构写一份自定义ShaderMaterial换进 pass,pattern()的点阵逻辑可以原样复用。
【免费下载链接】three.jsJavaScript 3D Library.项目地址: https://gitcode.com/GitHub_Trending/th/three.js
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考