ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

把真实HTML塞进WebGL:原理、交互与踩坑实战

把真实HTML塞进WebGL:原理、交互与踩坑实战 第一次看到这类开源库的时候我第一反应是这怕不是把三个大坑叠一块儿了。3D 场景里要做 UI本来就烦浏览器里渲染真实 HTML本来就耗还要把两者塞进同一个 WebGL 帧循环想想都觉得性能会炸。但它确实做到了而且做得还不赖。这类库的核心逻辑就是让真实 HTML 作为活纹理跑进 WebGL一边把网页内容一帧帧画进 GPU 纹理一边把用户在 3D 空间里的点击、滚动、输入重新传回 DOM。过去在 3D 场景里放“网页”的做法有多蠢干过的人都知道。要么把网页截图贴到模型上图是死的换一个状态就得重新截图要么在 WebGL 里从头画 UI一个按钮就是一组顶点加一堆交互逻辑成本高还难看还有的人用 CSS3D 把真实 DOM 悬浮在 canvas 外面看着像三维效果但它根本没有真的进 WebGL既不能参与深度测试也不能被模型遮挡。也正是因为这些痛点当“HTML 转 WebGL 纹理 事件回传”这种思路的开源库出现时我直接上头了。1. 为什么“让真实 HTML 跑进 WebGL”不是炫技而是刚需1.1 HTML/CSS 是当前唯一成熟的富 UI 系统如果你做过 WebGL 数据可视化或者 3D 编辑器一定会遇到这个问题场景里需要一个操作面板上面有下拉框、滑块、表格、折叠面板。这些组件在普通网页里一套 Element Plus 或 Ant Design 就搞定了但在 WebGL 里呢对不起全部要自己画。自绘 UI 的代价是真实的要处理九宫格切图缩放、文本折行、滚动条、焦点、键盘导航、无障碍、表单校验做完这些基本等于把浏览器重写了一遍。而 HTML/CSS 是当下最成熟的 UI 描述语言。排版、样式、动画、响应式、组件生态全都现成。把一个真实活动的 HTML 元素直接搬进三维场景可以省掉一大半 UI 开发成本。尤其在做产品演示、数字孪生这类“演示型”项目时业务方最想看到的就是“这个工厂的监控大屏如果能飘在厂房模型上空就好了”这种需求本质上就是把 HTML UI 放进 WebGL。1.2 老办法的痛点截图是死的自绘是贵的CSS3D 是假的我梳理一下既往方案方便理解这套库到底解决什么问题老方案能不能显示真 HTML交互深度遮挡维护成本截图贴图能但是静态快照无正常极低但每次变化要重新截图WebGL 自绘 UI不能自行实现正常很高CSS3D 叠加能实时原生不参与深度会被 canvas 遮挡中等第一行的截图贴图最典型。我给客户做在线展厅的时候最开始就是把企业官网首页截成一张 1920 的图贴到展厅里的虚拟屏幕上。确实能看但做得再精细也是假的——鼠标移上去不会有 hover 效果按钮点了没反应网页内容更新了你还要去重新导出图片。客户看五分钟就会说能不能让它“活”一点。第二行自绘 UI我之前在游戏 HUD 里也干过画一个简单的进度条和技能按钮就折腾了整整两天。优点是可定制程度高性能拉满但开发成本和后期维护真的顶不住。如果是业务型 3D 场景核心价值在数据和空间结构上不该把人力耗在重造 UI 上。第三行的 CSS3D看着是 3D其实还是 DOM 叠在 canvas 上面。Three.js 的 CSS3DRenderer 就是这条路线。它炫技效果好做卡片墙、全景信息点非常惊艳交互也是原生 DOM。但它跟 WebGL 场景不在同一个渲染世界里任何 WebGL 里的模型都没法真的遮挡它光照、阴影、环境反射一概不用想。1.3 为什么现在才有人把这条路走通过去不是没人想这么做而是浏览器能力没跟上。把 DOM 转成位图老办法是 html2canvas 一类的库靠内部自己重新解析 DOM 和 CSS碰到复杂样式就丢三落四。现在除了这些库越来越稳浏览器还给了 OffscreenCanvas、CanvasRenderingContext2D 的 createImageBitmap、SVG foreignObject 这些更底层的手段配合 WebGL2 和 WebGPU 的出现才真正可能把“网页渲染”当成一个实时纹理源来用。所以这个“开源库有点离谱”离谱在它把原来的“截图一次”变成了“动态直播”而且把交互闭环打通了。这种能力在以前要么是自研引擎的专利要么就只能靠非常 hack 的奇技淫巧。举个例子做一个汽车外观定制器。用户在左侧选择车身颜色和轮毂样式右侧的 3D 模型实时变化。传统做法是在 3D 模型里画一个屏幕屏幕上的内容是 Canvas2D 手绘的色板和轮毂列表。换成这类库选择面板本身就是一个真实的 HTML 组件把它纹理化后贴到 3D 场景的车载中控屏上用户点击中控屏上的选项通过事件回传控制模型换色。整个面板的样式、动画、校验全部复用前端现成组件开发量骤减。2. 从 DOM 到 GPU 纹理一条“网页直播”流水线2.1 静态模式先把网页拍成一张照片最朴素也最稳定的用法是“快照模式”。流程只有三步把 DOM 元素画到 canvas 上把 canvas 包装成纹理交给 WebGL 采样。这里最常用的落地方案是 html2canvas、dom-to-image、dom-to-image-more或者是原生 SVG foreignObject 技巧。核心代码大致长这样const $page document.getElementById(page); // 1. DOM - canvas 位图 const canvas await domtoimage.toCanvas($page, { width: 1024, height: 1024, }); // 2. canvas - WebGL 纹理以 three.js 为例 const texture new THREE.CanvasTexture(canvas); texture.colorSpace THREE.SRGBColorSpace; texture.generateMipmaps true; texture.minFilter THREE.LinearMipmapLinearFilter; texture.magFilter THREE.LinearFilter; texture.needsUpdate true;这段代码里很多人会忽略纹理参数。generateMipmaps和LinearMipmapLinearFilter是防止 3D 表面出现“摩尔纹”和远处闪烁的关键。缺了它网页纹理在缩小的时候会闪个不停。2.2 动态模式把拍照改成直播如果网页内容会变化例如面板上的价格倒计时、滚动列表、折叠面板状态那就要上动态模式。动态模式的核心不是“定时截屏”而是监听变化再重绘用MutationObserver监听 DOM 子节点、属性和样式变化用requestAnimationFrame或定频器驱动渲染变化的矩形区域尽量只重绘这一块避免整页级重拍上传纹理时用texSubImage2D局部更新而不是整张重传实际项目中完整实现“脏矩形 局部上传”工程量不小。好在开源库里这一类往往已经封装好你只需要给它一个根元素和一个纹理尺寸它会自己安排重绘时机。它内部一般长这样const htmlTexture new HtmlTexture({ element: document.querySelector(#live-page), width: 1024, height: 1024, mode: live, maxFPS: 30, }); // 在某帧渲染前调用 htmlTexture.update();这个update()方法做的事本质上就是把我上面说的监听、重绘、上传全部浓缩成了一次调用。开源库的“离谱”点就在这里复杂的浏览器排版 GPU 纹理同步对使用者只剩一个方法。2.3 它为什么是“真”的而不是“仿”的很多人会担心html2canvas 之类的库对 CSS 支持有限会不会出来的效果跟浏览器里看到的不一样这种担心是对的尤其对 box-shadow、backdrop-filter、grid 布局这类特性老库确实会翻车。所以新一代方案更倾向用浏览器原生能力去“截屏”比如 SVG foreignObject 把 DOM 包进去再导出或者用浏览器 DevTools 协议在后台标签页渲染页面。但不管底层是哪种实现本质都一样真正干活的不是 WebGL 里的任何代码而是浏览器排版引擎。GPU 收到的从来不是“HTML 源码”而是排版引擎算好的一整块 RGBA 像素。所以字体、图片、CSS 动画、阴影这些都是真渲染出来的效果不是 WebGL 手动模拟的“假页面”。可以这么理解你等于在浏览器里开了一个小剧场舞台上的演员是那些 DOM 元素WebGL 只是拿着摄像机对着舞台拍再把拍到的画面贴在三维模型上。3. 交互回传点击、滚动、输入不穿帮的细节3.1 纹理本身不会接收事件但坐标能穿回去这是最容易踩坑也最炫酷的部分。纹理对于 GPU 来说只是颜色数据WebGL 不会知道用户“点到了网页上的按钮”。所以要做事件回传思路是通过射线检测算出用户在 3D 空间中点到哪个模型再把命中点反算回“网页坐标”最后在 DOM 上手动派发一个同样坐标的事件。坐标变换链路大致是这样用射线检测拿到命中点的世界坐标例如 three.js 的Raycaster乘模型矩阵的逆矩阵转到模型局部坐标把局部坐标映射成纹理 UV 坐标范围归一化到 [0, 1]再把 UV 坐标放大到dom.clientWidth × dom.clientHeight的屏幕坐标用这个坐标创建MouseEventdispatchEvent到目标元素上用代码看更清楚function localPointToDom(localPoint, geometrySize, domEl) { // 假设 mesh 是 4x4 的平面几何中心在原点 const u localPoint.x / 4 0.5; // 归一化到 [0,1] const v localPoint.y / 4 0.5; // WebGL 纹理坐标系左下角为原点但网页坐标左上角为原点所以 Y 要翻转 const domX u * domEl.clientWidth; const domY (1 - v) * domEl.clientHeight; return { x: domX, y: domY }; } mesh.addEventListener(click, (event) { const localPoint event.point.clone().applyMatrix4(mesh.matrixWorld.clone().invert()); const { x, y } localPointToDom(localPoint, new THREE.Vector3(4, 4), uiPanel); uiPanel.elementFromPoint(x, y)?.dispatchEvent( new MouseEvent(click, { clientX: x, clientY: y, bubbles: true }) ); });注意第三步的 Y 翻转90% 的“点不准”都出在这里。网页坐标系是左上角原点、Y 向下而 WebGL 纹理坐标是左下角原点、Y 向上这两个方向不做反转点击位置就会上下颠倒。3.2 滚动、键盘和输入框一堆细节光是点击还不够。真要“用起来”还得处理滚轮滚动、触摸滑动、键盘焦点和输入框。滚轮事件相对简单在 3D 模型上捕获wheel把deltaY转成scrollBy(0, deltaY)传给目标 DOM 容器。触摸事件也可以类似处理用 Pointer Events 统一转。这里有个坑3D 场景里的旋转操作通常也用触摸因此要做一个逻辑判断——如果触点命中在“网页纹理”区域就优先滚动网页而不是旋转相机避免两个操作打架。输入框是最麻烦的。用户点中了input你要先把焦点给到那个 input然后把键盘按键派发过去。通常做法是input.focus()后监听全局keydown再构造KeyboardEvent派发最后手动触发input事件确保 Vue/React 的双向绑定能更新。有些库更狠干脆在 3D 场景旁边放一个隐藏的透明 input真正击键内容先进它再同步到纹理里的目标输入框。iframe 内容是一个无解项。如果网页里嵌的是 iframe浏览器安全策略会禁止把 iframe 独立绘制到 canvas所以遇到这种场景要么把嵌入内容换成同源要么就把 iframe 区域留白否则一定会黑屏或抛 SecurityError。3.3 多个平面、多个模型的拾取冲突如果场景里只有一个“网页屏幕”事情简单。但常有多个屏幕、多个面板还要叠在模型不同面上这时就要注意射线检测要取最近命中而不是第一个命中的物体每个面板要有独立的 UV 映射和图集偏移场景物体发生位移或旋转后要重新计算矩形命中区域面板是弯曲表面比如圆柱上的贴纸时UV 反算会变形得用测试点校准这些细节开源库一般不会替你处理引擎只给你“把平面坐标变成 DOM 坐标”的工具剩下的设计和调参还是得自己来。4. 上手 Demo把真实网页贴到旋转立方体上4.1 选型思路为了说清楚我直接做一个小 demo把一个真实的 HTML 面板贴到 three.js 的旋转立方体上并且让点击面板上的按钮能弹提示。技术选型我坚持保守组合three.js 管 3D 场景dom-to-image-more 管 DOM 转 canvas事件转发自己写因为代码量不大还能顺便讲清原理为什么不直接上某个现成的“HTML 纹理库”因为那会把原理埋在里面真出了问题你很难排查。先自己拼一遍再决定要不要用封装库。4.2 核心实现先准备一段 HTML 面板div idui-panel stylewidth: 400px; padding: 16px; background: #fff; border-radius: 12px; h3商品详情/h3 p stylecolor: #f40; font-size: 28px;¥ 3999/p button idbuy-btn立即购买/button /div然后把它纹理化贴到立方体上import * as THREE from three; import domtoimage from dom-to-image-more; const uiPanel document.getElementById(ui-panel); const scale 2; // 1. 按比例生成高分辨率位图 const canvas await domtoimage.toCanvas(uiPanel, { width: uiPanel.offsetWidth * scale, height: uiPanel.offsetHeight * scale, style: { transform: scale(${scale}), transformOrigin: top left, }, }); // 2. 包装成纹理 const texture new THREE.CanvasTexture(canvas); texture.colorSpace THREE.SRGBColorSpace; texture.generateMipmaps true; texture.minFilter THREE.LinearMipmapLinearFilter; // 3. 渲染器与场景 const renderer new THREE.WebGLRenderer({ antialias: true }); const scene new THREE.Scene(); const camera new THREE.PerspectiveCamera(45, 1, 1, 1000); camera.position.set(0, 0, 8); const cubeMat new THREE.MeshStandardMaterial({ map: texture, roughness: 0.6 }); const cube new THREE.Mesh(new THREE.BoxGeometry(4, 4, 4), cubeMat); scene.add(cube); scene.add(new THREE.AmbientLight(0xffffff, 0.8)); scene.add(new THREE.DirectionalLight(0xffffff, 1)); // 4. 动画循环 renderer.setAnimationLoop(() { cube.rotation.y 0.005; renderer.render(scene, camera); }); document.body.appendChild(renderer.domElement);这里有个小坑如果直接用源 DOM 的原始尺寸去生成纹理#ui-panel只有 400px 宽贴到 4x4 的几何体上必然模糊。所以我用scale(2)把渲染内容放大再配合导出宽高让位图在内部完成放大。这个技巧对清晰度影响很大。注意这里只是演示“能贴上”实际工程里更推荐把网页纹理贴到PlaneGeometry这类平面模型上并且把几何体宽高比和纹理宽高比调成一致不然会有明显拉伸。4.3 让按钮在 3D 场景里也能点接下来加射线检测和事件转发const raycaster new THREE.Raycaster(); const pointer new THREE.Vector2(); // 因为 uiPanel 可能被 canvas 遮挡这里自己遍历一遍 DOM 来找目标元素 function elementFromPointWithin(root, x, y) { const all [...root.querySelectorAll(*)].reverse(); for (const el of all) { const r el.getBoundingClientRect(); if (x r.left x r.right y r.top y r.bottom) return el; } return root; } renderer.domElement.addEventListener(click, (event) { const rect renderer.domElement.getBoundingClientRect(); pointer.x ((event.clientX - rect.left) / rect.width) * 2 - 1; pointer.y -((event.clientY - rect.top) / rect.height) * 2 1; raycaster.setFromCamera(pointer, camera); const hits raycaster.intersectObject(cube); if (hits.length 0) return; const uv hits[0].uv; // 纹理坐标 - 面板内逻辑坐标 const domX uv.x * uiPanel.clientWidth; const domY (1 - uv.y) * uiPanel.clientHeight; // 面板在页面上的实际位置 const panelRect uiPanel.getBoundingClientRect(); const viewportX panelRect.left domX; const viewportY panelRect.top domY; const el elementFromPointWithin(uiPanel, viewportX, viewportY); if (el) { el.dispatchEvent(new MouseEvent(click, { bubbles: true, clientX: viewportX, clientY: viewportY, })); } });注意这里我是直接用 three.js 射线命中结果里的hit.uv省掉了一次矩阵逆变换。然后在 DOM 侧自己写了一个elementFromPointWithin用来在“被 canvas 遮挡的源 DOM”里找目标元素。这个函数会遍历面板内所有子元素判断视口坐标是否落在某个元素边界内子元素优先保证最里层的按钮被命中。这个 demo 跑起来后立方体在转但点击时射线会正确命中那个旋转面按钮能正常触发。第一次跑通的时候我自己都愣了一下因为这种感觉太像“在 3D 里用真网页”了。5. 实测最容易翻车的 4 个坑模糊、白屏、掉帧、点不准5.1 模糊纹理尺寸和过滤参数不对症状很典型离远看还行贴近成大果粒旋转时高频闪烁。原因无非三个canvas 分辨率太低、没开 mipmap、纹理坐标被过度拉伸。解法也很固定导出位图尺寸至少 1024模型表面大的用 2048开启 mipmap 和各向异性过滤texture.anisotropy renderer.capabilities.getMaxAnisotropy()纹理宽高比尽量与平面几何体宽高比一致把这一套装了之后清晰度的改善是肉眼可见的。代价是纹理内存上去了所以建议从 1024 起步测试不要无脑上 4096三四个 4096 贴图就能把移动端显存撑爆。5.2 白屏字体、跨域图片、canvas 污染白屏是最让人崩溃的。纹理区域一片白或者直接报 SecurityError控制台偶尔还有 CSS 加载警告。这通常是两个原因第一页面引用的外部 CSS 还没加载完成就执行了 DOM 转图样式全丢渲染出来自然接近白板。解法是在window.load或document.fonts.ready之后再截。第二图片跨域导致 canvas 被污染toDataURL、getImageData、toBlob全部失效。解法是给跨域图片加crossOriginanonymous并确保服务器返回Access-Control-Allow-Origin。还有一类更隐蔽的问题html2canvas 类库对backdrop-filter、部分较新的 CSS 属性支持不稳定遇到白屏要优先怀疑样式而不是库坏了。可以先跑一个纯文本、纯色背景的最小 HTML 去隔离问题。5.3 掉帧实时模式不是免费的开启 live 模式后WebGL 里别的动画开始卡页面滚动都变慢这种问题几乎每个人都会遇到。原因不难理解每次更新都在重新排版 DOM、重新光栅化、上传纹理都是高成本操作。如果监听的是整个 body那一次小改动也会触发全量重绘。我的优化顺序是这样监听范围缩小到实际变化的容器而不是整页合并短时间内的多次变更例如用 20ms 的定时器做节流下调maxFPS到 15~30网页上大部分 UI 动效在 30 帧下已经足够顺滑能用静态纹理就坚决不开实时模式如果只是图片或视频区域在动考虑把动态部分单独用video元素捕获避免整页重新绘制还有一个很多人忽略的点更新纹理时用texSubImage2D局部上传比整张重新上传快很多。three.js 里 CanvasTexture 整图更新很省事但脏矩形局部更新通常要自己写底层 GL 调用性能差距非常明显。5.4 点不准Y 轴翻转和模型矩阵点不准的问题我排错过很多次。现象通常有两种点击按钮没反应但点击按钮上方或下方一点就触发模型转了一圈后点击偏移。原因也集中在两处没有处理纹理 UV 与 DOM 坐标的 Y 轴翻转模型发生位移旋转后没有用最新的matrixWorld做逆变换我的排查经验是在测试阶段做一个十字线调试工具把射线算出的 DOM 坐标显示在页面角落点击后看偏移量。把 Y 翻转、缩放偏移逐一排除之后再关掉调试。这个工具特别有效我几次点不准都是靠它定位的。它实际上就是一个固定定位的半透明提示层每次点击把算出的坐标值实时展示出来对比鼠标真实位置和计算位置差多少一目了然。6. 到底该不该用它三条路线的对比与我的选型建议6.1 三种方案横向对比每次我给项目选型都会在三种方案之间权衡方案能显示真实 HTML交互深度遮挡典型性能适合场景DOM 转纹理能需要自己转发事件正常中等产品展示、数字孪生、网页贴模型CSS3D 叠加能原生不参与深度高3D 卡片墙、全景信息面板WebGL 自绘 UI不能自行实现正常最高游戏 HUD、强视觉定制 UI门窗场景很容易判断如果目标是“把真实网页内容放进三维空间”DOM 转纹理是唯一真正进 GPU 的方案如果只是为了“看起来是 3D 的网页排版”CSS3D 成本最低如果是高性能游戏那还是老老实实自绘。6.2 动态更新频率是关键判断标准我的个人判断标准很简单如果 UI 内容是“静态图文 低频变化”比如展示一个产品详情页用快照模式贴图就够了如果 UI 高频变化比如实时监控面板、聊天消息列表、股票行情就用实时 DOM 转纹理并且一定要做脏矩形优化如果 UI 数据其实可以抽象成结构化数据根本不需要复杂排版那么老老实实自绘或者用 Canvas2D 画性价比反而最高。不要因为标题“离谱”就觉得它是万能药。真实 HTML 进 WebGL 是有成本的核心成本在浏览器排版引擎的每一次重绘以及 GPU 带宽。记住纹理更新是一笔持续的 CPU、GPU 开销静止场景别开 live动态场景要给重绘画红线。6.3 开源协议和商业项目注意点这个品类的开源库绝大多数是 MIT 或 Apache-2.0 协议可以商用但商业项目里要查两件事一是依赖树里有没有协议更严格的上游库二是有没有捆绑字体和图片资源。HTML 转图类库本身一般没问题但如果你的网页里用了付费字体或版权图片把它们转成纹理再展示版权责任仍然在资产本身不在库。这一点在交付给客户前最好在项目里过一遍 license audit。6.4 我的最后建议我自己用下来的感受是把实时 DOM 纹理和事件回传结合是这个品类价值最大的部分。一开始我也觉得渲染一张静态贴图就够了但当我在 WebGL 场景里真的用鼠标去点那个“网页”上的按钮看见它像正常网页一样响应、弹窗、改变状态那种“两个世界被打通”的感觉才是这个库真正值回票价的地方。如果你的项目恰好需要这种透明体验我建议先拿一个不会频繁重绘的小面板跑通闭环再逐步放大到整页性能问题通常都能通过缩小重绘范围解决。
返回列表