
打开项目仓库的README我写的第一句话就是为什么非要在网页里做视频投射这不是自问自答凑字数而是过去半年被问得最多的一句话。传统展厅、舞台背景、车展大屏用的都是昂贵的视频拼接处理器几路HDMI信号进来经过几何校正、边缘融合再输出到异形LED或投影幕上。这套链路稳定但贵——一台中等规格的拼接处理器就是几千上万调试师傅上门一天也要按千计。而Three.js视频融合、视频投射这套方案目标只有一个把本来要砸硬件的活儿用WebGL在浏览器里做完而且开源出来让所有人改得起、玩得起。这篇文章不是官方文档翻译是我把自己从零搭到开源的全过程梳理了一遍。适合三种人一是展厅、活动策划、多媒体装置方向的技术人员想评估Web方案能不能替代传统硬件二是Three.js刚入门、始终搞不懂视频纹理和UV映射的开发者三是手头有多块屏幕拼一个画面不规则表面投射视频这类需求的设计师。核心内容围绕VideoTexture的底层机制、单面投射的完整实现、多路视频无缝拼接融合的接缝处理以及我在实际部署中踩过的性能坑展开。1. 先用一句话说透视频投射和视频贴图到底是不是一回事先说结论在Three.js里视频投射Video Projection本质上是视频贴图VideoTexture加UV改造再加上投射的视觉包装。很多新手一上来搜视频投射以为要引入什么高级Shader、要搞投影矩阵、要做射影几何其实完全不是。1.1 VideoTexture你的视频就是一个会动的纹理Three.js的VideoTexture做的事情非常朴素把一个HTMLVideoElement对象绑定成WebGL纹理。每一帧WebGL从视频里取当前画面更新到纹理上。你把它贴到任何材质上视频就长在那个几何体表面了。const video document.createElement(video); video.src /assets/demo.mp4; video.loop true; video.muted true; video.playsInline true; video.play(); const texture new THREE.VideoTexture(video); texture.colorSpace THREE.SRGBColorSpace; // 颜色空间后面细说 const material new THREE.MeshBasicMaterial({ map: texture });这段代码看起来简单但背后藏了几个关键设置缺一个都容易翻车muted true大多数浏览器禁止有声视频自动播放静音是绕开自动播放策略的唯一稳妥手段。如果非要声音就得让用户点击页面后手动video.play()。playsInline trueiOS Safari上不加这个视频会强制全屏播放纹理直接黑屏。loop true展厅场景通常永不结束循环播放是刚需。colorSpaceThree.js 152版本以后默认走线性工作流视频纹理不解锁SRGB的话画面上会蒙一层灰蒙蒙的褪色感对比度也不对。这个坑我调了一下午。1.2 UV映射视频画面是怎么贴到曲面上的有了纹理下一个问题就是怎么把视频画面放到任意形状的面上。Three.js里每个几何体的顶点都有UV坐标UV是0到1之间的二维坐标U对应纹理的横向V对应纵向。平面几何体的UV是均匀分布的所以直接贴不会有任何扭曲。但一旦换成圆柱、球体、环形或不规则曲面默认UV可能导致画面拉伸方向不对、接缝位置错误。传统投影工程师说的话在这里完全适用画面有没有形变取决于你用哪种网格去接它。你的几何体网格越贴合投射目标视频画面贴上去就越像真的投射在物体表面。这个阶段最重要的认知是UV坐标就是你的投影机位。同一个视频放在另一个UV映射的网格上视觉结果千差万别。视频投射的绝大多数工作其实是在编辑器里调UV、改几何体分段数而不是写多少行Shader。1.3 三种投射形态平面投射、曲面投射、多机位拼接按目标表面分类我的开源项目里把投射拆成了三种基础形态形态适用场景关键技术点平面投射LED屏、投影幕、墙面基本零UV改造PlaneGeometry即可曲面投射柱状屏、弧面幕、异形装置自定义UV调整几何体分段纹理沿曲率方向不扭曲多路拼接融合多块屏幕拼大画面、多投影仪融合每路视频独立纹理UV连续化边界羽化文章后面会专门讲第三种因为多屏幕拼一个完整画面才是视频融合的核心需求也是我开源这个项目的主要动机。2. 从零搭建一个可运行的单面投射Demo做技术分享最怕上来就甩大段代码。我先把最小可运行的路径走一遍让你在十分钟内看到视频投射到立体表面上的效果然后再展开讲每一段背后的选型逻辑。2.1 项目初始化为什么我会选Vite而不是CDN直接引Three.js的官方仓库里有大量纯CDN引用示例但我在正式项目里一律用Vite构建。原因很实际CDN方式需要处理import map、模块路径和版本锁定一旦项目规模上来后面要做融合、后期、交互模块化拆分是刚需。Vite的启动速度足够快配合three和vite-plugin-static-copy处理静态资源整个过程没有编译等待。npm create vitelatest video-projection -- --template vanilla cd video-projection npm install three mkdir -p public/assets把视频文件丢到public/assets/demo.mp4接下来就是一个标准的Three.js场景。2.2 场景、相机与一个带弧度的投射面我故意没有用平面而是用一个CylinderGeometry的局部片段来模拟弧形屏幕。这样更能体现投射区别于贴图的地方——画面要顺着曲面延续不能被拉花。import * as THREE from three; import { OrbitControls } from three/addons/controls/OrbitControls.js; const scene new THREE.Scene(); const camera new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 100); camera.position.set(0, 0, 8); const renderer new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(window.innerWidth, window.innerHeight); document.body.appendChild(renderer.domElement); new OrbitControls(camera, renderer.domElement);然后是核心投射面。我用一个半圆弧面半径3、高度2、弧度120度、分段数32。分段数是曲面平滑度的关键太少会看到明显棱线太多会增加顶点计算量。弧形幕布场景32段是性能与画质的平衡点。const geometry new THREE.CylinderGeometry(3, 3, 2, 32, 1, true, -Math.PI / 3, Math.PI / 3 * 2); geometry.rotateZ(Math.PI / 2); const material new THREE.MeshBasicMaterial({ map: texture, side: THREE.DoubleSide }); const screen new THREE.Mesh(geometry, material); scene.add(screen);注意这里用的是MeshBasicMaterial。投射视频时除非你真有动态光照要计算否则不要用MeshStandardMaterial——它默认要算光照、环境贴图、阴影对视频纹理来说是纯浪费。展厅屏幕一般自己是发光体Basic材质语义上就正确。2.3 动画循环里唯一必须做的一件事视频纹理是有生命的每一帧都在变化。渲染循环里Three.js默认会在每帧调用renderer.render()时自动更新VideoTexture所以你不需要手动调texture.needsUpdate true。但有一个例外要记住如果你有一台独立的显示器或投影仪输出或者把渲染频率降到了30fps此时视频纹理的更新时机可能和视频播放帧对不齐看起来会撕裂卡顿。我习惯的做法是让渲染循环持续跑即使场景静止也不要renderer.setAnimationLoop(null)。WebGL渲染器只要在循环中纹理更新就是连续的。真要省电优化可以用requestVideoFrameCallback做事件驱动的更新这个放到性能章节再细说。2.4 把画面打到曲面上的UV调整直接用默认的CylinderGeometry贴视频会出现一个问题画面从圆弧的起始边到结束边U坐标是0到1均匀展开的。如果你的视频本身是16:9横构图而弧面宽度远大于高度画面就会被上下拉伸变形。这里的处理思路是先确定曲面展平后的宽高比再反向决定视频的截取区域。我给你一个最直接的办法用一个简单的工具函数把视频纹理的texture.repeat和texture.offset调成匹配弧面实际尺寸的值。// 假设弧面展平后宽5米高2米视频分辨率1920x1080 // 视频本身宽高比 1.777弧面宽高比 2.5需要让纹理在U方向缩小 const surfaceAspect 5 / 2; // 2.5 const videoAspect 1920 / 1080; // 1.777 texture.wrapS THREE.ClampToEdgeWrapping; texture.wrapT THREE.ClampToEdgeWrapping; texture.repeat.set(1, 0.711); texture.offset.set(0, (1 - 0.711) / 2);repeat.y 0.711意味着纹理在V方向只取中间71.1%的部分相当于把视频裁掉了上下黑边区域正好匹配弧面比例。这是实战中高频使用的小技巧——不是所有视频素材都是为你的屏幕专门拍的做投射适配的第一步永远是裁而不是拉。3. 多路视频无缝拼接融合一场关于坐标系和边缘羽化的硬仗单面投射是热身。真正让我决定开源的项目模块是多路视频拼接融合。说人话就是你有三块物理上并排的屏幕或者一台电脑输出三路视频要让它们整体显示一个连续的画面且屏幕之间不能有明显的亮度断层和几何错位。这个需求在线下展厅、车展互动、博物馆沉浸空间里出现得非常频繁。3.1 多面幕布的坐标统一所有子面必须共享一套UV传统拼接处理器的做法是对每路输入做几何校正把画面边缘对齐。而Three.js方案里更优雅的做法是先做一个合并的曲面模型然后把多路视频分别附加到曲面的不同分段上。换句话说你要的不是三块独立的屏而是一块几何上连续的大屏只是这个大屏由三张不同的VideoTexture纹理喂养。以三块并排平面为例我把一个大的PlaneGeometry按顶点位置拆成三个子Mesh每个子Mesh的材质是独立的VideoTexture。理论上只要这三段几何体的UV在U方向上是连续递增的比如第一段0~0.33第二段0.33~0.66第三段0.66~1.0画面拼起来就不会有几何错位。实际操作中UV连续递增这个目标藏着一个细节默认PlaneGeometry的每个子面UV都是从0到1的如果你直接创建三个PlaneGeometry再并排放每个面的视频都会显示完整画面——三块屏各播各的根本不是融合。必须手动重排UVfunction updatePlaneUV(geometry, uStart, uLength) { const uv geometry.attributes.uv; for (let i 0; i uv.count; i) { let u uv.getX(i); uv.setX(i, uStart u * uLength); } uv.needsUpdate true; } const seg1 new THREE.Mesh(planeGeo, mat1); const seg2 new THREE.Mesh(planeGeo, mat2); const seg3 new THREE.Mesh(planeGeo, mat3); updatePlaneUV(seg1.geometry, 0, 1 / 3); updatePlaneUV(seg2.geometry, 1 / 3, 1 / 3); updatePlaneUV(seg3.geometry, 2 / 3, 1 / 3);这样第一段视频的右边缘正好接在第二段视频的左边缘视觉上是完整画面。3.2 三段视频的画面内容如何错位播放有人到这里会问三段视频素材怎么准备是不是要提前用剪辑软件裁切三个片段不必。融合系统的核心优势在于运行时裁切。每路视频播放的是同一段完整素材但要让它只显示对应屏幕区域的内容。Three.js里用texture.offset.x实现// 视频素材1920x1080播放同一个文件 mat1.map.offset.set(0, 0); // 显示左侧1/3画面 mat2.map.offset.set(1 / 3, 0); // 显示中间1/3 mat3.map.offset.set(2 / 3, 0); // 显示右侧1/3 // 别忘了每路纹理的repeat.x要变成1/3否则还是全幅画面 mat1.map.repeat.set(1 / 3, 1); mat2.map.repeat.set(1 / 3, 1); mat3.map.repeat.set(1 / 3, 1);这就是为什么重复素材也能拼出连续画面的原因。只要你保证三路video.play()的启动时间误差在两帧以内肉眼就察觉不到错位。多路同步的工程细节我会在后面实验里展开。3.3 边缘融合为什么拼缝处总有一条亮线直接做三块屏UV拼接最常见的现象是接缝处有一条明显的亮线或暗线。这个问题的本质是我们在处理离散采样时的边缘效应——每路纹理的右边缘像素和下一路纹理的左边缘像素各自独立采样颜色值本身连续但边缘处纹理会因为ClampToEdgeWrapping出现微小差异再加上显示器本身的黑边、LED箱体间的物理缝隙融合就成了必修课。传统投影融合方案里边缘融合的思路是重叠区压暗、渐变过渡。Three.js里我采用Alpha通道羽化的方案实现方式如下每块子屏用MeshBasicMaterial({ transparent: true })。在几何体右边缘附近重写顶点的Alpha属性或使用顶点色让边缘透明渐变。相邻两块屏在边缘有小范围几何重叠透明渐变部分叠加在一起补偿亮度。更高效的做法是写一个自定义Shader在片元着色器里根据UV的横向位置计算羽化系数。我开源项目里用的是第二种因为可调性强不用改几何体结构。// 片元着色器片段uEdgeWidth控制羽化宽度 float smoothFactor 1.0; if (vUv.x uEdgeWidth) { smoothFactor smoothstep(0.0, uEdgeWidth, vUv.x); } else if (vUv.x 1.0 - uEdgeWidth) { smoothFactor 1.0 - smoothstep(1.0 - uEdgeWidth, 1.0, vUv.x); } gl_FragColor vec4(texture2D(uMap, vUv).rgb, smoothFactor * uOpacity);注意羽化宽度不是越宽越好。太宽的画面看起来像整天起雾太窄的又遮不住箱体缝隙。我的经验值是重叠区占单屏宽度的3%~5%具体取决于你用的LED箱体边框宽度。这个值必须现场调离线永远调不准。3.4 多路视频同步播放的工程化方案视频融合场景里三路视频如果各自play()几十毫秒的启动误差都会让拼合画面错位。我踩过坑最后采用的是等待所有视频进入可播放状态再统一启动的策略const videos [video1, video2, video3]; Promise.all(videos.map(v { return new Promise(resolve { if (v.readyState 2) return resolve(); v.oncanplay resolve; }); })).then(() { videos.forEach((v, i) { v.currentTime startAt; // 统一起点 v.play(); }); });readyState 2意味着视频已经有当前帧数据此时play()基本是瞬间出画不会出现一路已经放了、另一路还在转圈的情况。如果有主控系统更精细的做法是用WebSocket或MIDI时钟信号统一触发但纯前端场景用Promise.all已经能保证感知不到错位。4. 我的排错实录贴图不显示、视频黑屏、纹理扭曲的全过程这部分是热词里Three.js贴图开始不显示和谷歌网页有three.js就卡卡的背后的真实问题。我从开源项目的issue区挑出了出现频率最高的三类问题用排错链路的写法还原当时的处理思路。4.1 现象一第一帧黑屏之后突然出现画面这是一个典型的视频还没就绪就当纹理用了的问题。Three.js在创建VideoTexture时会立刻把当前视频帧上传为纹理。如果此时视频还没有解码出任何一帧纹理内容就是黑色后续画面出来了只有needsUpdate被触发时才更新。在autoplay策略下这个问题尤其明显很多人把video.play()和new THREE.VideoTexture(video)放在同一个函数里导致纹理创建的时机早于视频首帧可用。排查链路是这样的检查video.readyState如果是0说明视频源还没加载元数据。监听loadeddata事件确保有帧数据后再创建纹理。如果用了oncanplay还是黑检查是否设置了video.muted——未静音的视频在自动播放策略下会被浏览器一直挂起。修复代码const video document.createElement(video); video.src /assets/demo.mp4; video.muted true; video.playsInline true; video.addEventListener(loadeddata, () { const texture new THREE.VideoTexture(video); material.map texture; material.needsUpdate true; video.play(); });这里有个小经验宁可先让视频跑起来、有帧数据了再建纹理也不要提前创建然后反复needsUpdate。后者会带来不必要的解码和上传开销。4.2 现象二一切正常但纹理灰蒙蒙的三个关键词colorSpace。Three.js自2022年的r150引入颜色管理后纹理如果不显式设置texture.colorSpace THREE.SRGBColorSpace画面会偏灰、偏淡。尤其是视频纹理很多老教程没提这一行代码导致新用户对照教程做出来总是不对味。这是因为视频纹理默认被当作线性颜色数据而线性数据直接输出到屏幕时视觉上丢失了Gamma校正画面暗部发灰、亮部过曝。处理办法就是加这一行。如果你的项目用了MeshBasicMaterial同时还要注意renderer.outputColorSpace的默认值保持二者一致即可。4.3 现象三视频纹理方向反了、画面倒置相对少见但必须知道不同设备的视频拍摄方向、不同编码格式的旋转元数据可能让VideoTexture的显示方向和源视频不一致。排查顺序是确认视频文件本身在播放器里方向正常。若正常但Three.js里倒置尝试texture.rotation Math.PI并调整texture.center到(0.5, 0.5)。若水平镜像了改texture.repeat.x -1然后调整texture.offset.x 1。这类问题没有统一的正确答案完全取决于素材来源所以我直接在项目里暴露了一个textureTransform配置项方便现场微调。这个设计决定后来帮了不少人——每个人拿到的视频素材五花八门统一处理逻辑根本不存在。4.4 现象四本地打开正常部署到线上后黑屏这基本就是CORS跨域问题。视频文件在http://127.0.0.1:5500下可以加载但放到CDN或不同域名的服务器上时浏览器会拦截视频帧的上传。检查方法打开浏览器开发者工具看Network面板里视频请求是否有CORS报错。解决办法是给存放视频的响应头加Access-Control-Allow-Origin: *而且这个头不是加在页面HTML上的是加在视频文件自身响应上的。如果视频放在同域静态目录下通常不会触发这个问题。注意crossOrigin anonymous可以加在video标签上但这不是万能药。如果服务端不返回CORS头加了这个属性反而会导致视频完全加载不出来。5. 性能优化让谷歌网页有three.js就卡卡的彻底成为过去式热词里有一条特别真实谷歌网页有three.js就卡卡的。很多用户把问题归咎于Three.js本身其实大多数时候是用法问题。视频纹理绝对是性能消耗的大头但我有一套从解码层到渲染层的完整优化策略开源项目里全部实现了。5.1 卡顿的第一根源GPU在拼命解码高分辨率视频视频纹理的每一帧都要经历浏览器解码 - 上传GPU - 纹理采样三个环节。一个4K视频即使只显示在很小的区域浏览器依然会按原始分辨率解码。这就是贴小图也卡的原因。最直接的优化是降低视频源分辨率。展厅场景的LED屏通常2米宽主视角下实际需要的像素量可能只有1080p甚至720p。以我做过的某次车展项目为例同一个画面用4K视频和1080p视频最终观感差异极小帧率差异极大。我项目里预设了三档视频资源高清档(1920x1080)、标准档(1280x720)、低清档(854x480)按设备devicePixelRatio自动选择。实测低端笔记本跑720p视频纹理可以稳定60fps4K则掉到30fps以下。5.2 内存与纹理上传释放掉你根本看不见的东西视频纹理的特点是只增不减。每次视频画面变化GPU里的纹理数据都在更新旧纹理如果没被释放就会积累成内存黑洞。具体操作// 场景切换不再需要某个视频纹理时 material.map.dispose(); texture.dispose(); video.pause(); video.src ; video.load();很多开发者在切换展厅场景时只做了scene.remove(mesh)忘了dispose。Three.js的remove只是把节点从场景图摘掉不会自动释放GPU资源。你可以看Chrome的Memory面板如果GPU Memory和JS Heap持续增长说明纹理泄漏了。5.3 渲染层优化缩小渲染尺寸、限制像素比另一个容易被忽视的参数是renderer.setPixelRatio。手机和4K屏上Three.js默认会用物理像素渲染一个3D场景全屏渲染的实际像素可能是1920x1080的好几倍。视频投射场景里几乎没有抗锯齿细节需求可以把像素比限制到1甚至0.75renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));在部分性能差的设备上我开源项目里会直接降到1。画质损失几乎不可感知帧率提升20%~40%。5.4 用requestVideoFrameCallback替代每帧都更新传统思路是渲染循环里每帧调renderer.render()。但视频投射场景中如果视频是30fps而渲染器跑到60fps实际上有一半渲染帧的纹理内容没有变化白白浪费GPU。一个更优的调度方式let lastVideoFrame -1; function renderLoop() { const hasNewFrame video.requestVideoFrameCallback ? true : video.currentTime ! lastVideoFrame; if (hasNewFrame) { lastVideoFrame video.currentTime; renderer.render(scene, camera); } requestAnimationFrame(renderLoop); }配合video.requestVideoFrameCallback可以拿到视频帧的精确回调实现视频帧更新一次场景渲染一次。对于纯静态场景相机不动的展厅大屏这个优化能把GPU占用降到接近零。但要注意如果场景里有其他动画比如旋转模型、光晕粒子就不要用这种节流渲染否则其他动画也会被降频。5.5 合并DrawCall把三块屏合成一次绘制多路视频融合时如果每块屏幕一个Mesh一个材质一个DrawCall三屏就是三次提交。我在项目里改成三路视频纹理合成到一张纹理图集Texture Atlas上然后使用一个Mesh、一个材质、一次DrawCall渲染整块屏幕。当然这个优化的前提是三路视频画面内容本来就要拼成一张完整画面。实际做法是把三路视频的动态画面实时drawImage到Canvas上再把这个Canvas作为单一VideoTexture。代价是CPU端多了一次Canvas合成但GPU端省了两次DrawCall和多次纹理切换。在低端显卡上收益明显。实现时注意Canvas的尺寸要统一到2的幂次否则部分移动GPU会不认。6. 开源项目的结构与二次开发建议这套方案的开源仓库最终沉淀成了几个清晰的模块。如果你要基于它做二次开发我建议直接按模块切入不要从主入口开始全文阅读。6.1 项目目录结构video-projection/ ├── docs/ # 部署、融合调试手册 ├── examples/ │ ├── single-surface/ # 单面投射Demo │ ├── multi-screen/ # 三屏拼接融合Demo │ └── curved-screen/ # 弧形屏投射Demo ├── src/ │ ├── core/ │ │ ├── VideoProjector.js # 核心投射器管理场景与相机 │ │ ├── ProjectionSurface.js # 投射面几何与UV管理 │ │ └── TextureManager.js # 视频纹理生命周期管理 │ ├── effects/ │ │ └── EdgeBlendMaterial.js # 边缘融合Shader │ └── utils/ │ ├── video-loader.js # 视频加载与同步播放 │ └── perf.js # 性能自适应 └── public/ └── assets/ # 演示视频素材二次开发时最常改的是ProjectionSurface和EdgeBlendMaterial。前者决定你的投射目标长什么样后者决定多屏融合的视觉效果。6.2 从单面屏扩展到球幕、异形装置视频投射最常见的进阶需求是球幕和异形装置。球幕的难点在于球面的UV经纬度展开会让视频画面上下的扭曲非常严重直接贴上去靠近南北极的内容会被压缩得面目全非。我的建议是先做UV的可视化调试工具把网格的UV坐标以颜色渐变形式渲染出来你能立刻看清每个区域的缩放比再决定是否需要对视频内容做预畸变处理。预畸变可以用Canvas做径向扭曲也可以在Shader里做逆变换采样。这个思路和传统投影里的网格校正一脉相承只是从硬件网格换成了软件坐标。6.3 开源协议与贡献方式项目采用MIT协议商用、改源码都不需要找我授权。但有一点希望使用者遵守如果你改出了更优秀的融合Shader或新的投射面类型欢迎向主仓库提交PR。投影融合这个圈子其实很垂直很多做传统拼接处理器的前辈Master了硬件方案但对Web方案抱有怀疑。我需要更多地实践案例去验证这条路你的场景反馈本身就是贡献。我自己在展厅行业跑了四年的经验是硬件拼接处理器短期内不会消失但Web视频投射的开源生态一旦起来对于那些预算有限、又追求异形和交互创意的小型项目来说会是一个非常值得考虑的替代选项。7. 最后说点项目之外的实话整个开源项目从搭建到稳定大概用了我三个多月的业余时间。很多人觉得Three.js视频投射是高不可攀的3D图形技术其实拆开来看核心就是视频变成纹理、纹理贴到UV正确的网格上、边缘过渡处理好。难点不在图形学理论而在那些没人替你踩过的工程细节。如果这篇文章能帮你少走一段弯路我的建议很直接不要光看源码先跑通single-surface那个Demo然后亲手把一台笔记本的输出转到你自己的显示器上试一次多屏融合。你会在十分钟内遇到三到五个我讲了半天的细节问题——那时候你再回来看这篇文章每一段都会变成有用的。说到底再复杂的装置艺术、再炫酷的数字展厅落到代码层面也就是纹理、UV、性能这六个字。把这三件事玩透你就能用一套完全开源的工具做出一台小型视频拼接处理器能做的所有事情而且还能做得更轻、更灵活、更好修改。这大概就是我做这个项目的全部理由。