WebRTC把摄像头采集能力直接带进了浏览器,但要实现实时视频滤镜,单靠WebRTC还不够,需要配合Canvas或WebGL完成像素级处理。整体思路并不复杂:调用getUserMedia拿到MediaStream对象,绑定到<video>元素上播放;随后在动画循环中把当前视频帧绘制到<canvas>,读取像素数据执行滤镜算法,最后写回画布或借助WebGL着色器渲染。下面拆解这条处理链路中的关键环节,并给出可以运行的核心代码。

一、从摄像头拿到稳定的视频流
navigator.mediaDevices.getUserMedia是浏览器提供的媒体采集接口。调用时需要传入constraints对象,指定video为true或更具体的分辨率、帧率。由于隐私策略限制,该API只在HTTPS或localhost环境下可用,Chrome、Firefox、Safari的权限提示略有不同,但接口基本统一。下面的示例中使用了ideal参数,浏览器会在不支持时自动降级,而不是直接报错。
async function startCamera() {
const constraints = {
video: { width: { ideal: 640 }, height: { ideal: 480 }, frameRate: { ideal: 30 } },
audio: false
};
try {
const stream = await navigator.mediaDevices.getUserMedia(constraints);
const video = document.getElementById('sourceVideo');
video.srcObject = stream;
await video.play();
return stream;
} catch (err) {
console.error('无法访问摄像头', err);
}
}
使用ideal而不是exact,是为了兼容更多设备。部分安卓机型的前后摄像头切换会改变采集分辨率,建议在loadedmetadata事件之后读取video.videoWidth和video.videoHeight,确认实际尺寸。滤镜处理依赖固定尺寸画布,拿到真实尺寸后再设置Canvas宽高,能避免画面拉伸。如果要切换前后摄像头,需要先停止旧轨道,再重新获取新流。获取到流之后,就可以把视频帧送入处理管线了。
二、Canvas 2D实现基础像素滤镜
Canvas 2D的API简单直观,适合实现灰度、反色、亮度调节等入门级滤镜。核心流程是先用drawImage把视频当前帧复制到Canvas上,再通过getImageData获取RGBA像素数组。每个像素占用4个字节,分别表示红、绿、蓝和透明度。修改这些数值后,用putImageData写回画布。下面是一个基于亮度加权公式的灰度滤镜实现。
function applyGrayscale(canvas, video) {
const ctx = canvas.getContext('2d');
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
const gray = data[i] * 0.299 + data[i + 1] * 0.587 + data[i + 2] * 0.114;
data[i] = gray;
data[i + 1] = gray;
data[i + 2] = gray;
}
ctx.putImageData(imageData, 0, 0);
}
灰度算法中的权重来源于人眼对绿色更敏感。如果直接取三通道平均值,画面会显得更暗。反色滤镜只需要让每个颜色通道等于255减去原值,亮度调整则是在原值上加上偏移量并限制在0到255之间。Canvas 2D的问题也很明显:高分辨率下每帧要遍历百万级像素,纯JavaScript循环会占用大量CPU时间。640x480约30万像素,也就是120万次通道计算,尚可接受;但1920x1080超过200万像素,中端笔记本可能掉到十几帧,于是需要更底层的方案。
三、用WebGL着色器把计算压力转移给GPU
WebGL允许编写片元着色器直接处理纹理中的每个像素。浏览器把视频帧绑定为纹理,GPU并行执行着色器程序,哪怕4K分辨率也能保持较高帧率。构建WebGL滤镜需要几个固定步骤:创建上下文、编译顶点和片元着色器、创建纹理并上传视频帧、绘制矩形。着色器使用GLSL编写,语法类似C语言。下面是一个实现灰度效果的片元着色器。
precision mediump float;
varying vec2 vTexCoord;
uniform sampler2D uTexture;
void main() {
vec4 color = texture2D(uTexture, vTexCoord);
float gray = dot(color.rgb, vec3(0.299, 0.587, 0.114));
gl_FragColor = vec4(vec3(gray), color.a);
}
上面的dot函数计算向量点积,等价于三通道加权求和。JS侧需要把视频帧用texImage2D上传到纹理,每次请求动画帧时更新。WebGL的初始化代码比Canvas 2D多不少,但一旦搭好框架,后续新增滤镜只需替换片段着色器中的数学表达式。例如反色可以写成gl_FragColor = vec4(1.0 - color.rgb, color.a),边缘检测则需要对邻域采样实现Sobel算子。下面的JS代码展示了纹理绑定与绘制循环的关键部分。
function renderWithWebGL(gl, video, texture) {
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, video);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
requestAnimationFrame(() => renderWithWebGL(gl, video, texture));
}
对比CPU方案,WebGL处理1080p视频时CPU占用明显更低,但会增加显存带宽压力。如果只是做简单灰度,Canvas 2D在中低分辨率下已经够用;如果叠加模糊、锐化、马赛克等复杂效果,GPU方案是更好的选择。实践中可以先在Canvas 2D上快速验证算法,再把算法翻译成GLSL,这样能降低调试成本。
四、帧循环与性能调优
无论采用哪种处理方式,都需要一个稳定的帧循环。requestAnimationFrame比setInterval更适合视频处理,因为它与显示器刷新率同步,避免不必要的重绘。注意不要在每一帧里重复创建ImageData或Canvas对象,它们应该提前缓存。若只是显示处理后的画面,可以使用两个Canvas:一个隐藏用于处理,一个可见用于展示,避免画面闪烁。
另一个优化方向是降低输入分辨率。很多滤镜效果在640x360下就能呈现,不必强行使用1080p。可以在getUserMedia中设置较低ideal值,或在drawImage时先绘制到较小画布。还可以尝试OffscreenCanvas和Web Worker,把像素计算移出主线程,防止UI卡顿。移动端上还要关注发热和降频,适当降低帧率到24或15帧,人眼在实时视频通话场景下仍能接受。
调试时可以开启浏览器的Performance面板,检查每帧耗时。典型指标包括getImageData、putImageData和着色器编译时间。对于Canvas 2D,getImageData和putImageData本身就有开销,可考虑使用Canvas的filter属性做简单的CSS滤镜,不过它不支持自定义卷积核。记住要在组件销毁时停止摄像头轨道,调用stream.getTracks().forEach(track => track.stop()),否则摄像头指示灯会一直亮着。

