1. 高清屏适配到底在解决什么问题如果你第一次打开自己写的WebGL页面画了一个三角形在高分屏上显示边缘毛毛糙糙或者整个画面像隔着一层磨砂玻璃别急着怀疑shader写错了十有八九是画布的实际像素和屏幕物理像素没对上。这个问题就是标题里说的“高清屏适配”有人叫Retina适配有人叫HiDPI适配本质是同一件事。我看了不少零基础同学的入门作品发现这是WebGL上手后第一个“看起来不对劲”的坎代码逻辑全对颜色没毛病但清晰度就是不行。先说清楚它解决的是什么。我们平时用Canvas 2D也碰到过同样现象一个300宽的逻辑画布在高分屏上被浏览器拉伸到一张物理像素密度多几倍的屏幕上最终呈现出来的画面自然就糊。WebGL和Canvas 2D在底层渲染模型上同源画布缓冲区里有多少像素、最终显示时映射到多少个屏幕发光点这两者不对齐画面就会糊。这篇文章会从像素概念开始讲给出零基础上手就能用的适配代码再把我实际调试时踩过的坑一五一十列出来最后聊聊纹理、文字、离屏渲染这些进阶场景怎么保持清晰。适合刚写完第一个WebGL程序、或者已经会用Canvas但还处理不好屏幕适配的人。1.1 物理像素、CSS像素与设备像素比先建立一个基础模型。屏幕显示图形的单位是物理像素就是屏幕上一个个能独立发光的点。早年普通显示屏上操作系统把一个逻辑点映射到一个物理点所以网页里写width: 100px显示出来就是100个物理点。后来高清屏普及屏幕物理像素数量大大增加但系统默认为了保证界面元素在视觉上大小不变不再是一个逻辑点对一个物理点而是让一个逻辑点对应2个甚至3个物理点。这个比值就是devicePixelRatio中文常叫设备像素比缩写DPR。实际开发里我们通过window.devicePixelRatio拿到它。普通外接显示器很多是1笔记本高清屏常见1.25、1.5手机和平板普遍2或者3。拿一台DPR为2的手机举例你在CSS里写一个宽300px的元素它实际占据的物理像素是600个。这是硬件决定的不是CSS那边能直接改的。很多零基础的同学会把devicePixelRatio当成一个“玄学参数”其实它就是系统告诉你“这个屏幕的物理像素密度是逻辑密度的几倍”。理解到这一层适配逻辑就通了我们要做的不是把画布改成多大尺寸而是让画布的渲染缓冲区跟屏幕物理像素对齐。1.2 默认情况下Canvas为什么会糊几乎没有什么适配事故是离奇的根源基本都在这条链路上。canvas元素有两个尺寸概念一是它的属性width和height这两个值决定canvas内部绘图缓冲区的实际像素数量二是CSS样式里的width和height决定canvas在页面上被拉伸、缩放后所占的布局尺寸。假设你只写了canvas width300 height150/canvasCSS没有设置尺寸那么canvas在页面上显示的尺寸默认等于属性值也就是300乘150。这时候在高分屏上浏览器发现这张画布要显示在DPR为2的屏幕上会把这300乘150的内容拉伸到600乘300个物理像素。多余的信息不存在只能靠插值补看起来自然就糊了。这种情况和一张只有300像素宽的图片放到600像素宽的位置显示完全一样。渲染引擎可以平滑插值但锐利边缘救不回来线条变粗、文字发虚、三角形边界有重影全是因为最终落屏的像素数超过缓冲区里的像素数。所以默认画出来的WebGL画面偏糊不是GPU、不是着色器、不是浏览器bug就是缓冲区太小。搞清楚这一点接下来所有解决方案都顺理成章。1.3 适配的本质让渲染缓冲对齐物理像素高清屏适配的核心操作可以概括成一句话让canvas的绘制缓冲区像素数等于它在屏幕上实际占据的物理像素数。这个“实际占据的物理像素数”怎么算先把canvas在页面布局中的CSS尺寸拿到再乘上DPR。比如CSS尺寸是400乘300的canvas放在DPR为2的屏幕上它最终覆盖800乘600个物理点那我们就应该把canvas.width设成800canvas.height设成600。这里有一个容易混淆的点CSS尺寸不能跟着乘。如果你把canvas的style.width也改成800px它会占更大页面空间最后物理像素变成1600个糊的问题依然存在页面布局还乱了。CSS尺寸要保持在页面上想要的逻辑大小缓冲区尺寸才是需要放大的部分。还有一个思维习惯要转变。WebGL里我们经常用正交或透视矩阵往着色器里传归一化设备坐标、世界坐标画布大小通常不会直接暴露在着色器逻辑里。但gl.viewport的宽高、以及投影矩阵的aspect比例用的必须是物理像素尺寸。很多适配失败案例就是viewport没跟着改结果画面要么被裁掉要么比例不对。2. 零基础可直接抄的适配方案2.1 第一步读取设备像素比读取设备像素比很简单const dpr window.devicePixelRatio || 1;加一个|| 1是为了兼容某些老环境或者特殊浏览器拿不到时按普通屏处理。注意不要用 dpr 1 这种判断来决定要不要适配因为DPR可能是浮点数比如1.25、1.5判断“等于1”不可靠。DPR不是一成不变的。同一个浏览器窗口从外接显示器拖到笔记本屏幕或者用户改了系统缩放比例window.devicePixelRatio都会变。所以不能只在页面初始化时读一次就再也不管后续要响应变化后文我会单独说。2.2 第二步按DPR设置缓冲区和CSS尺寸假设页面上有一个canvas元素CSS已经占好了位置。推荐做法是先用clientWidth和clientHeight取CSS布局尺寸再乘DPR设置缓冲区function resizeCanvasToDisplaySize(canvas) { const dpr window.devicePixelRatio || 1; const cssWidth canvas.clientWidth; const cssHeight canvas.clientHeight; canvas.width Math.floor(cssWidth * dpr); canvas.height Math.floor(cssHeight * dpr); canvas.style.width cssWidth px; canvas.style.height cssHeight px; }为什么用Math.floor而不是Math.round因为乘法结果可能带小数比如CSS宽度是100.5DPR是1.25乘积是125.625。canvas.width的属性值落地为整数时向下取整更稳避免超出实际物理像素范围多出一根像素的边缘。有人会问为什么要手动设置style.width和style.height因为当我们修改canvas.width属性后许多浏览器会把它当前的展示尺寸也重置为这个属性值把画布在页面上撑得很大。重置style尺寸是为了确保页面布局不被破坏。新手特别容易漏这一步漏了就会出现“为什么页面突然多出滚动条”的诡异问题。2.3 第三步更新视口并保持宽高比设置好缓冲区和CSS尺寸之后还有一步不能漏——gl.viewport。WebGL渲染是把顶点坐标映射到视口范围内的默认视口可能只占画布一小块或者不跟随画布尺寸变化。所以每次改变画布尺寸都必须重新设置gl.viewport(0, 0, canvas.width, canvas.height);同时只要你的着色器里有涉及宽高比的投影矩阵也要同步更新。典型做法是先算aspect再重建投影矩阵const aspect canvas.width / canvas.height; const projectionMatrix mat4.perspective(fov, aspect, near, far);这种同步不是可选项而是必需项。很多同学改完缓冲区尺寸后画面依然不对转头开始怀疑shader结果一查是viewport没有更新。出现整体比例被拉宽、变窄或者只有部分内容可见时第一个怀疑对象就是viewport或投影矩阵的aspect没跟上。动手前建议先确认坐标系里到底谁用“逻辑尺寸”。我们在着色器里定义的几何体坐标通常是抽象坐标真正落到屏幕像素要经过viewport变换。viewport用多少像素几何体就被映射到多少像素的范围上。如果viewport还是旧值画面自然就不正确。2.4 顺手封装成一个可复用函数前面这个过程再加上viewport和投影矩阵更新建议封装成一个统一的resize处理器function resizeRenderer(gl, canvas, updateProjection) { const dpr window.devicePixelRatio || 1; const displayWidth Math.floor(canvas.clientWidth * dpr); const displayHeight Math.floor(canvas.clientHeight * dpr); if (canvas.width ! displayWidth || canvas.height ! displayHeight) { canvas.width displayWidth; canvas.height displayHeight; canvas.style.width canvas.clientWidth px; canvas.style.height canvas.clientHeight px; gl.viewport(0, 0, canvas.width, canvas.height); if (updateProjection) { updateProjection(canvas.width / canvas.height); } } }判断canvas.width ! displayWidth是为了避免没变化时反复触发。重设属性、清空缓冲区会带来开销即使很小在频繁resize事件下积累起来也会拖帧。如果你用性能工具观察会看到加上这个判断后resize期间帧率更稳。这个函数已经足够放进任何一个小项目里。之后每当页面尺寸变化、方向变化、DPR变化时调用它即可。对一个入门项目来说适配部分到这里已经结束了后面是更完整的实操演示。3. 一个完整例子高清三角形从零跑到屏幕3.1 从HTML到绘制的最小流程这一节走一个最小但完整的示例。页面里有一个div容器canvas在里面自适应。然后在JavaScript里初始化WebGL上下文应用适配函数再画一个三角形。div idcontainer canvas idglCanvas/canvas /div#container { width: 100%; height: 400px; } #glCanvas { width: 100%; height: 100%; display: block; }注意canvas的CSS宽度用100%高度用容器高度。canvas不在HTML属性上写死尺寸完全依赖JS按客户端尺寸初始化。这样页面怎么变适配函数都能算出正确的物理尺寸。JavaScript部分const canvas document.getElementById(glCanvas); const gl canvas.getContext(webgl); if (!gl) { console.error(当前环境不支持WebGL); } resizeRenderer(gl, canvas, () {}); const vsSource attribute vec2 aPosition; void main() { gl_Position vec4(aPosition, 0.0, 1.0); } ; const fsSource precision mediump float; void main() { gl_FragColor vec4(1.0, 0.3, 0.2, 1.0); } ; // 编译着色器、链接program的标准代码这里省略 // 假设已经得到 program gl.useProgram(program); const buffer gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, buffer); const vertices new Float32Array([ 0.0, 0.8, -0.8, -0.6, 0.8, -0.6 ]); gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW); const aPosition gl.getAttribLocation(program, aPosition); gl.enableVertexAttribArray(aPosition); gl.vertexAttribPointer(aPosition, 2, gl.FLOAT, false, 0, 0); gl.clearColor(0.95, 0.95, 0.98, 1.0); gl.clear(gl.COLOR_BUFFER_BIT); gl.drawArrays(gl.TRIANGLES, 0, 3);把这段跑起来三角形是锐利的边上不会出现一圈半透明的渐变过渡也不会有两条几乎重叠的线条。在DPR为2、3的屏幕上它和DPR为1的普通屏观感一致这就是高清适配起作用了。你可能注意到这个demo里顶点坐标没有使用具体像素换算用的还是标准归一化设备坐标。这正是WebGL的一个特点顶点最终如何映射到屏幕像素是viewport决定的。我们适配时把viewport设成物理像素尺寸顶点坐标照常归一化最终三角形边缘覆盖到的物理像素就对了。3.2 适配函数到底改了谁为了让你彻底看明白我把三者的关系拉出来梳理一遍。canvas.width / canvas.height内部缓冲区大小等于物理像素数。canvas.style.width / style.height页面布局大小等于CSS逻辑尺寸和平时写div一样。gl.viewport(0, 0, canvas.width, canvas.height)告诉GPU渲染结果要映射到这个物理像素范围的视口。这三个值配合正确后画布显示在屏幕上的每一个物理像素都对应真实的渲染像素不会出现“一个像素要渲染给两个物理点还凑不齐数据”的情况。这就是高清适配的完整秘密没有更多玄机。3.3 交互坐标的修正点击不到想要的位置画布适配后如果你还要做鼠标拾取、点击选点之类的交互算坐标时就不能直接把鼠标的CSS像素坐标丢给几何计算。因为画布内部坐标体系已经是物理像素你需要把鼠标坐标转换到物理像素坐标function mouseToPhysical(e, canvas) { const rect canvas.getBoundingClientRect(); const scaleX canvas.width / rect.width; const scaleY canvas.height / rect.height; return { x: (e.clientX - rect.left) * scaleX, y: (e.clientY - rect.top) * scaleY }; }这里rect.width是CSS逻辑宽canvas.width是物理像素宽两者相比得到缩放比例。不做这一步鼠标点击位置会偏移特别是在DPR为2或3的屏上偏差会非常明显。如果你在着色器或数学里使用的是NDC再把物理像素坐标除以canvas.width、canvas.height得到[0,1]区间再映射到想要的坐标系。这个链路虽然多一步但每一步都是明确的出问题也好排查。4. 常见故障与排查速查4.1 最典型的问题对照表适配就那么几行代码但翻车现场极其统一。我把常见症状整理成一张速查表方便你对照排查。症状可能原因处理办法画布边缘模糊、文字发虚缓冲区尺寸小于物理像素尺寸按DPR重设canvas.width/height画面只显示一小块或被裁切gl.viewport没有跟随缓冲区更新渲染前调用gl.viewport(0,0,width,height)页面布局被撑大出现滚动条设置canvas.width后style.width被重置手动设置style.width和style.height点击位置和画面元素对不上鼠标坐标直接用了CSS像素用canvas.width / rect.width做缩放某些帧闪一下然后变模糊resize时DPR取到了旧值在resize回调里重新读取DPR这张表基本覆盖了零基础到中级玩家百分之八十的问题。剩下那百分之二十大多出在纹理和离屏渲染第五部分展开说。4.2 用对比法快速定位问题遇到高清屏模糊问题我的调试顺序是先做对比实验临时强制DPR为1重新初始化画布。如果强制DPR为1后画面在屏幕上清晰锐利说明适配链路本身是对的问题出在DPR倍数获取或缩放时机上。如果强制DPR为1后画面更糊那说明问题不在适配而是渲染内容本身或者CSS尺寸设置。这个对比法非常管用。很多人debug时喜欢去翻shader、看混合模式实际上就是被“先怀疑渲染”的思路带偏了。先对比再定位能省很多时间。4.3 观察DPR变化与隐藏的resize另一个容易被忽略的坑是设备像素比不是固定的。你把浏览器窗口从一块普通屏拖到一块高分屏devicePixelRatio会变化用户调整系统显示比例也会变化。万一只在页面加载时初始化一次拖拽窗口后画面就会变糊或错位。正确做法是监听resize事件在这里重新执行适配。要注意有些浏览器在窗口拖拽过程中的resize事件会连续触发几十次。为了性能加一个轻量防抖let resizeTimer; window.addEventListener(resize, () { clearTimeout(resizeTimer); resizeTimer setTimeout(() { resizeRenderer(gl, canvas, updateProjection); }, 100); });如果希望更精准地监听canvas元素本身尺寸变化而不是整个窗口可以用ResizeObserver。它比window resize更贴近业务容器宽度变了就触发不会因为页面其他区域变化而空跑。用法简单const observer new ResizeObserver(() { resizeRenderer(gl, canvas, updateProjection); }); observer.observe(canvas.parentElement);两种方案都可以。我习惯在纯静态demo里用window resize加防抖在界面结构复杂的页面里用ResizeObserver。核心逻辑都一样每次显示尺寸变化后重新同步缓冲区、CSS尺寸和viewport这三件套。5. 进阶纹理、离屏渲染和高性能取舍5.1 纹理在高分屏上依然发虚是怎么回事画布本身适配好了不代表纹理就自动清晰。纹理清晰度取决于图片资源本身的分辨率和屏幕DPR是两套逻辑。举例你从一张640x360的图片创建纹理显示到画布上一个逻辑尺寸为320x180的区域。如果屏幕DPR是2这个区域实际会用640x360个物理像素去显示图片刚好一一对应很清晰。但如果图片本身只有320x180显示在这个物理像素区域内就要插值放大看起来就糊。这时无论怎么调canvas适配都救不回来因为信息量在资源端就缺了。解决思路是准备不同倍率的图片资源。逻辑尺寸320x180的区域至少准备640x360的资源DPR为3时准备960x540的更稳。这和前端做响应式图片一样本质都是要喂给屏幕足够多的像素信息。非贴图类UI纹理图标、文字、边框最值得额外准备2x资源因为人眼对这类元素的锐利度最敏感。5.2 离屏渲染与帧缓冲的尺寸陷阱如果你的项目用了帧缓冲也就是离屏渲染帧缓冲关联的纹理或渲染缓冲对象的尺寸也必须按物理像素设置否则后期效果会出问题。很多实现了高斯模糊、辉光、阴影的项目主canvas适配做好了但中间缓冲还是按CSS尺寸创建最后把中间缓冲贴回屏幕时图像被放大拉伸产生模糊或锯齿。正确做法是中间缓冲宽高也乘上DPRconst targetWidth Math.floor(cssWidth * dpr); const targetHeight Math.floor(cssHeight * dpr); const texture gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, texture); gl.texImage2D( gl.TEXTURE_2D, 0, gl.RGBA, targetWidth, targetHeight, 0, gl.RGBA, gl.UNSIGNED_BYTE, null );尤其要注意那些依赖纹理坐标的后期效果纹理尺寸不同会导致采样坐标范围变化。如果主画布和离屏纹理尺寸不一致要么在做后处理时专门传入旧尺寸的uniform要么干脆让所有中间缓冲和主画布保持一致。后者的复杂度低得多中小项目优先推荐。5.3 别在Canvas里硬画文字能分层就分层WebGL里渲染文字一直是个麻烦事。直接用Canvas 2D的fillText画到纹理里再贴到三维模型上普通屏勉强能用高分屏上字号、缩放、抗锯齿都会变得很难控制文字经常发虚、边缘有毛刺。我的建议是UI层文字优先用普通DOM元素叠加在canvas上层不要试图写进WebGL场景。这样浏览器会负责文字在物理像素级别的清晰渲染你几乎不用管适配。只有必须作为场景一部分出现的文字比如3D标签、广告牌才用纹理方案。如果一定要在纹理中绘制文字需要额外开大离屏canvas的尺寸比如逻辑区域的两倍再通过DPR比例绘制字形然后作为纹理使用。这部分工作量和坑都比较多除非确有需要否则别碰。5.4 性能取舍不是所有屏幕都要铺满3倍像素最后说一个刚接触适配的人最容易忽略的问题适配和高性能可能冲突。如果你做的是一个复杂场景一帧要画几十万个顶点在高DPR下画布物理像素数量非常大。比如一台DPR为3的设备全屏画布可能达到7680x4320像素以上像素填充量是普通1080p屏的十几倍。GPU即使顶点数不变光栅化和像素着色的成本也会暴涨帧率直接掉一半都很正常。所以在做重场景时很多人会设置一个DPR上限const maxDpr 2; const dpr Math.min(window.devicePixelRatio || 1, maxDpr);这等于在清晰度和性能之间做取舍。不少上线项目会把maxDpr设成2再开抗锯齿或后处理来弥补观感。对于实时渲染、可视化大屏这类场景与其执着于3倍像素不如保证稳定帧率带来的流畅体验。给零基础同学一个参考模型面数不高、效果简单直接用满DPR没问题场景复杂或需要持续交互先把上限设成2看帧率再调整。开发调试时把上限放开发布前再按目标设备校准。最后分享一个小技巧我调试适配问题时会在页面角落放一个实时标签显示当前DPR和canvas物理尺寸。画面糊的时候一眼就能看出缓冲区有没有跟上屏幕不用靠肉眼反复放大对比。如果你同时被“画面模糊”和“页面布局错乱”两个问题夹击多数情况下是style尺寸被意外重设了优先检查那一步。适配高清屏真的不复杂把缓冲尺寸、CSS尺寸、viewport三者对齐再留一条响应resize的链路WebGL画面在高分屏上的清晰度问题基本就解决了。
阅读完成 · 觉得有帮助?