##文本渲染优化 文本在游戏中应用的比较多,cocos引擎本身的文本渲染存在模糊问题,具体是由于在webgl模式下,不同设备的dpr不一样,导致渲染效果不一致,表现在高分屏设备上canvas文字模糊。 ##现有的方案 每个文本在添加的时候设置字体放大2倍,行高放大2倍,node再缩小到0.5 ##问题点 手动设置容易忘记,而且处理其他相关逻辑的时候还要做缩放比例转换,编辑处理比较麻烦,不设置效果比较模糊。 ###可能的原因分析 ####画布栅格(canvasgrid)以及坐标空间 ![栅格](/api/file/getImage?fileId=622e828749ddfd5b2b000aa1) 如图所示,有个宽150px,高150px的canvas元素,canvas元素默认被网格所覆盖。通常来说网格中的一个单元相当于canvas元素中的一像素。栅格的起点为左上角(坐标为(0,0))。所有元素的位置都相对于原点定位。所以图中蓝色方形左上角的坐标为距离左边(X轴)x像素,距离上边(Y轴)y像素(坐标为(x,y)) ###1px问题 用网格来代表 canvas的坐标格,每一格对应屏幕上一个像素点。在第一个图中,填充了 (2,1) 至 (5,5) 的矩形,整个区域的边界刚好落在像素边缘上,这样就可以得到的矩形有着清晰的边缘。 如果你想要绘制一条从 (3,1) 到 (3,5),宽度是 1.0 的线条,你会得到像第二幅图一样的结果。实际填充区域(深蓝色部分)仅仅延伸至路径两旁各一半像素。而这半个像素又会以近似的方式进行渲染,这意味着那些像素只是部分着色,结果就是以实际笔触颜色一半色调的颜色来填充整个区域(浅蓝和深蓝的部分)。这就是上例中为何宽度为 1.0 的线并不准确的原因。 要解决这个问题,你必须对路径施以更加精确的控制。已知粗 1.0 的线条会在路径两边各延伸半像素,那么像第三幅图那样绘制从 (3.5,1) 到 (3.5,5) 的线条,其边缘正好落在像素边界,填充出来就是准确的宽为 1.0 的线条。 这是由于绘制时,将画笔中线对齐坐标轴线,必然有一半线宽在坐标轴线之外,这一半的线宽,如果不是整数,会被自动加满,使之正好对齐一个像素点,由于本身是不满这1个像素的,所以用了更浅的颜色来表示(改变透明度),导致模糊。(因为1像素就是最小单位了,0.x的像素不能显示 线宽为 1.0 在放大 2 倍后,会变成清晰的线宽为 2.0,并且出现在它应该出现的位置上。 ![](/api/file/getImage?fileId=622e828749ddfd5b2b000aa2) ``` 画线可以通过调整0.5来获得1px的高清线,但是字体设置+0.5的字号并没有效果 ``` ### web解决方法 ```javascript function getContext(w,h){ const canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h; const ctx = canvas.getContext('2d'); const dpr = window.devicePixelRatio; canvas.width = w * dpr canvas.height = h * dpr canvas.style.width = w + 'px' canvas.style.height = h + 'px' ctx.scale(dpr, dpr); return ctx } ``` > 从解决方法上很难推测是否是栅格的计算方式问题引起的字体模糊,我是这么考虑的,我们在绘制文字的时候,不管用灰阶还是子像素,文字渲染最终还是通过矢量加贝塞尔打点的方式去处理,这个过程中计算出的点的位置大概率是小数点,因此绘制文字图的时候就没法占全正好一个像素点,然后点成线和图的时候,填充差值计算有会产生像素偏移,然后就造成透明度和字体模糊,然后解释x2不模糊问题在于产生小数点的过渡色在缩小的过程中被差值计算的采样算法抵消,错上加错恢复了原来的清晰度 ###是否和devicePixelRatio相关联 从web的方案看是和dpr相关联,并且依赖dpr进行运算,所以说有关系,但是关系可能不是乘dpr这么简单,我们仔细考虑一下,假设我们的dpr是2,设计尺寸是750x1336,这个设计尺寸是按照iphone5的分辨率为参考,iphone5的dpr=2,因此在设计图上28px的字体其实已经是预乘了2倍的结果,但是在绘制到context上显示的适合还是模糊了,从下面的测试用例上也可以看出来,dpr为2的设备chrome强制改成1也依然模糊,我们通过ccc修改设置2倍font size再缩回来就变清晰了,相当于我dprx4再缩小到2倍才变清晰,所以还是webgl下canvas绘制的问题。 ###cocos creator解决方案 #### 第一次修复 ![第一次修复](/api/file/getImage?fileId=622e862449ddfd5b2b000ab0) 按照web的修复方式尝试修复后,none模式修复成功了,但是当换成char或者bitmap模式,会发现文字只有一半 > web的方法在ccc3上只能在none模式下适用,bitmap方式和char模式是直接取的ctx生成纹理,因此canvas的缩放并不太适合,并且和官方同学确认过,在部分平台上(微信等平台)不兼容style的设置方式,因此此方法证明可以修复,但是需要适配ccc3的使用方式 #### 第二次修复 修复思路还是按照先x2再缩小的方法,但是字体的三种模式的处理方式不一样,因此我们需要针对性做修改 在ccc3里,字体的3种模式分别对应assembler关系如下 |模式 | assembler|处理方式| |:----:| :----: |:----: | |none| ttf| 通过设置node的contentsize控制放大后的canvas | |bitmap|bmfont|不需要处理| |char|letter|通过设置bmfont的fontscale控制放大后的字体| ###修复后遗症 通过这种方式修复存在的问题 1. 纹理变大,导致内存和动态纹理占有相比未放大的时候上升3倍 2. 借用的contentsize来设置,因此手动设置label的contentsize失效 ###优点 1. 清晰度提升,能够真实还原字体 2. 不破坏字体原有的边缘和平滑度 ### 无聊的代码 > engine/cocos/2d/assembler/label/font-utils.ts ```typescript export function getFontScaleFactor ():number { return 2; } const _dpr = getFontScaleFactor(); // Math.min(Math.ceil(screenAdapter.devicePixelRatio), 2); private _upScaleFontDpr (context: CanvasRenderingContext2D | null) { if (!context) { return; } context.font = context.font.replace( /(\d+)(px|em|rem|pt)/g, (w, m:string, u:string) => (+m * _dpr).toString() + u, ); } private _downScaleFontPx (context: CanvasRenderingContext2D | null) { if (!context) { return; } context.font = context.font.replace( /(\d+)(px|em|rem|pt)/g, (w, m:string, u:string) => (+m / _dpr).toString() + u, ); } ``` ``` private _updateProperties () { this.data = CanvasPool.getInstance().get(); this.canvas = this.data.canvas; this.context = this.data.context; if (this.context) { this.context.font = this.labelInfo.fontDesc; const width = safeMeasureText(this.context, this.char, this.labelInfo.fontDesc); const blank = this.labelInfo.margin * 2 + bleed; this.width = parseFloat(width.toFixed(2)) * _dpr + blank; this.height = this.labelInfo.fontSize * _dpr + BASELINE_RATIO * this.labelInfo.fontSize + blank; this.offsetY = -(this.labelInfo.fontSize * BASELINE_RATIO) * _dpr / 2; } if (this.canvas.width !== this.width) { this.canvas.width = this.width; } if (this.canvas.height !== this.height) { this.canvas.height = this.height; } if (!this.image) { this.image = new ImageAsset(); } this.image.reset(this.canvas); } private _updateTexture () { if (!this.context || !this.canvas) { return; } const context = this.context; const labelInfo = this.labelInfo; const width = this.canvas.width; const height = this.canvas.height; context.textAlign = 'center'; context.textBaseline = 'alphabetic'; context.clearRect(0, 0, width, height); // Add a white background to avoid black edges. context.fillStyle = _backgroundStyle; context.fillRect(0, 0, width, height); context.font = labelInfo.fontDesc; const fontSize = labelInfo.fontSize; const startX = width / 2; const startY = height / 2 + fontSize * _dpr * ((BASELINE_RATIO / _dpr + 1) / 2 - BASELINE_RATIO / _dpr) + fontSize * _dpr * BASELINE_OFFSET; const color = labelInfo.color; // use round for line join to avoid sharp intersect point context.lineJoin = 'round'; context.fillStyle = `rgba(${color.r}, ${color.g}, ${color.b}, ${1})`; this._upScaleFontDpr(context); if (labelInfo.isOutlined) { const strokeColor = labelInfo.out || WHITE; context.strokeStyle = `rgba(${strokeColor.r}, ${strokeColor.g}, ${strokeColor.b}, ${strokeColor.a / 255})`; context.lineWidth = labelInfo.margin * 2 * _dpr; context.strokeText(this.char, startX, startY); } context.fillText(this.char, startX, startY); this._downScaleFontPx(context); // this.texture.handleLoadedTexture(); // (this.image as Texture2D).updateImage(); } } ``` > engine/cocos/2d/assembler/label/ttfUtils.ts ``` const _dpr = getFontScaleFactor(); updateRenderData (comp: Label) { if (!comp.renderData) { return; } if (comp.renderData.vertDirty) { const trans = comp.node._uiProps.uiTransformComp!; this._updateFontFamily(comp); this._updateProperties(comp, trans); this._calculateLabelFont(); this._updateLabelDimensions(); this._updateTexture(comp); this._calDynamicAtlas(comp); comp.actualFontSize = _fontSize; trans.setContentSize(new Size(_canvasSize.width / _dpr, _canvasSize.height / _dpr)); this.updateVertexData(comp); this.updateUVs(comp); comp.markForUpdateRenderData(false); _context = null; _canvas = null; _texture = null; } if (comp.spriteFrame) { const renderData = comp.renderData; renderData.updateRenderData(comp, comp.spriteFrame); } } _updateProperties (comp: Label, trans: UITransform) { const assemblerData = comp.assemblerData; if (!assemblerData) { return; } _context = assemblerData.context; _canvas = assemblerData.canvas; _texture = comp.spriteFrame; _string = comp.string.toString(); _fontSize = comp.fontSize; _drawFontsize = _fontSize; _overflow = comp.overflow; _nodeContentSize.width = trans.width; _nodeContentSize.height = trans.height; _canvasSize.width = trans.width * _dpr; _canvasSize.height = trans.height * _dpr; _underlineThickness = comp.underlineHeight; _lineHeight = comp.lineHeight; _hAlign = comp.horizontalAlign; _vAlign = comp.verticalAlign; _color = comp.color; _alpha = comp.node._uiProps.opacity; _isBold = comp.isBold; _isItalic = comp.isItalic; _isUnderline = comp.isUnderline; if (_overflow === Overflow.NONE) { _isWrapText = false; } else if (_overflow === Overflow.RESIZE_HEIGHT) { _isWrapText = true; } else { _isWrapText = comp.enableWrapText; } // outline _outlineComp = LabelOutline && comp.getComponent(LabelOutline); _outlineComp = (_outlineComp && _outlineComp.enabled && _outlineComp.width > 0) ? _outlineComp : null; if (_outlineComp) { _outlineColor.set(_outlineComp.color); } // shadow _shadowComp = LabelShadow && comp.getComponent(LabelShadow); _shadowComp = (_shadowComp && _shadowComp.enabled) ? _shadowComp : null; if (_shadowComp) { _shadowColor.set(_shadowComp.color); } this._updatePaddingRect(); } _calculateFillTextStartPosition () { let labelX = 0; if (_hAlign === HorizontalTextAlignment.RIGHT) { labelX = (_canvasSize.width / _dpr) - _canvasPadding.width; } else if (_hAlign === HorizontalTextAlignment.CENTER) { labelX = ((_canvasSize.width / _dpr) - _canvasPadding.width) / 2; } const lineHeight = this._getLineHeight(); const drawStartY = lineHeight * (_splitStrings.length - 1); // TOP let firstLinelabelY = _fontSize * (1 - BASELINE_RATIO / 2); if (_vAlign !== VerticalTextAlignment.TOP) { // free space in vertical direction let blank = drawStartY + _canvasPadding.height + _fontSize - _canvasSize.height / _dpr; if (_vAlign === VerticalTextAlignment.BOTTOM) { // Unlike BMFont, needs to reserve space below. blank += BASELINE_RATIO / 2 * _fontSize; // BOTTOM firstLinelabelY -= blank; } else { // CENTER firstLinelabelY -= blank / 2; } } firstLinelabelY += _BASELINE_OFFSET * _fontSize; _startPosition.set(labelX + _canvasPadding.x, firstLinelabelY + _canvasPadding.y); } _updateTexture (comp: Label) { if (!_context || !_canvas) { return; } _context.clearRect(0, 0, _canvas.width, _canvas.height); _context.font = _fontDesc; this._calculateFillTextStartPosition(); const lineHeight = this._getLineHeight(); // use round for line join to avoid sharp intersect point _context.lineJoin = 'round'; if (_outlineComp) { _context.fillStyle = `rgba(${_outlineColor.r}, ${_outlineColor.g}, ${_outlineColor.b}, ${_invisibleAlpha})`; // Notice: fillRect twice will not effect _context.fillRect(0, 0, _canvas.width, _canvas.height); // to keep the one model same as before // Todo: remove this protect when component remove blend function // @ts-expect-error remove when component remove blend function } else if (comp._srcBlendFactor === BlendFactor.SRC_ALPHA) { _context.fillStyle = `rgba(${_color.r}, ${_color.g}, ${_color.b}, ${_invisibleAlpha})`; _context.fillRect(0, 0, _canvas.width, _canvas.height); } _context.fillStyle = `rgb(${_color.r}, ${_color.g}, ${_color.b})`; const drawTextPosX = _startPosition.x; let drawTextPosY = 0; // draw shadow and underline this._drawTextEffect(_startPosition, lineHeight); // draw text and outline this._upScaleFontDpr(_context); for (let i = 0; i < _splitStrings.length; ++i) { drawTextPosY = _startPosition.y + i * lineHeight; if (_outlineComp) { _context.strokeText(_splitStrings[i], drawTextPosX * _dpr, drawTextPosY * _dpr); } _context.fillText(_splitStrings[i], drawTextPosX * _dpr, drawTextPosY * _dpr); } this._downScaleFontPx(_context); if (_shadowComp) { _context.shadowColor = 'transparent'; } this._uploadTexture(comp); } _setupOutline () { _context!.strokeStyle = `rgba(${_outlineColor.r}, ${_outlineColor.g}, ${_outlineColor.b}, ${_outlineColor.a / 255})`; _context!.lineWidth = _outlineComp!.width * 2 * _dpr; } _drawTextEffect (startPosition: Vec2, lineHeight: number) { if (!_shadowComp && !_outlineComp && !_isUnderline) return; const isMultiple = _splitStrings.length > 1 && _shadowComp; const measureText = this._measureText(_context!, _fontDesc); let drawTextPosX = 0; let drawTextPosY = 0; // only one set shadow and outline if (_shadowComp) { this._setupShadow(); } if (_outlineComp) { this._setupOutline(); } // draw shadow and (outline or text) for (let i = 0; i < _splitStrings.length; ++i) { drawTextPosX = startPosition.x; drawTextPosY = startPosition.y + i * lineHeight; // multiple lines need to be drawn outline and fill text if (isMultiple) { this._upScaleFontDpr(_context); if (_outlineComp) { _context!.strokeText(_splitStrings[i], drawTextPosX * _dpr, drawTextPosY * _dpr); } _context!.fillText(_splitStrings[i], drawTextPosX * _dpr, drawTextPosY * _dpr); this._downScaleFontPx(_context); } // draw underline if (_isUnderline) { _drawUnderlineWidth = measureText(_splitStrings[i]); if (_hAlign === HorizontalTextAlignment.RIGHT) { _drawUnderlinePos.x = startPosition.x - _drawUnderlineWidth; } else if (_hAlign === HorizontalTextAlignment.CENTER) { _drawUnderlinePos.x = startPosition.x - (_drawUnderlineWidth / 2); } else { _drawUnderlinePos.x = startPosition.x; } _drawUnderlinePos.y = drawTextPosY + _drawFontsize / 8; _context!.fillRect(_drawUnderlinePos.x * _dpr, _drawUnderlinePos.y * _dpr, _drawUnderlineWidth * _dpr, _underlineThickness * _dpr); } } if (isMultiple) { _context!.shadowColor = 'transparent'; } } _calculateLabelFont () { if (!_context) { return; } const paragraphedStrings = _string.split('\n'); _splitStrings = paragraphedStrings; _fontDesc = this._getFontDesc(); _context.font = _fontDesc; switch (_overflow) { case Overflow.NONE: { let canvasSizeX = 0; let canvasSizeY = 0; for (let i = 0; i < paragraphedStrings.length; ++i) { const paraLength = safeMeasureText(_context, paragraphedStrings[i], _fontDesc); canvasSizeX = canvasSizeX > paraLength ? canvasSizeX : paraLength; } canvasSizeY = (_splitStrings.length + BASELINE_RATIO) * this._getLineHeight(); const rawWidth = parseFloat(canvasSizeX.toFixed(2)); const rawHeight = parseFloat(canvasSizeY.toFixed(2)); _canvasSize.width = (rawWidth + _canvasPadding.width) * _dpr; _canvasSize.height = (rawHeight + _canvasPadding.height) * _dpr; _nodeContentSize.width = rawWidth + _contentSizeExtend.width; _nodeContentSize.height = rawHeight + _contentSizeExtend.height; break; } case Overflow.SHRINK: { this._calculateShrinkFont(paragraphedStrings); this._calculateWrapText(paragraphedStrings); break; } case Overflow.CLAMP: { this._calculateWrapText(paragraphedStrings); break; } case Overflow.RESIZE_HEIGHT: { this._calculateWrapText(paragraphedStrings); const rawHeight = (_splitStrings.length + BASELINE_RATIO) * this._getLineHeight(); _canvasSize.height = rawHeight * _dpr + _canvasPadding.height; // set node height _nodeContentSize.height = rawHeight + _contentSizeExtend.height; break; } default: { // nop } } } ``` > engine/cocos/2d/assembler/label/bmfontUtils.ts ``` enableDpr (v: boolean) { _enableDpr = v; } updateRenderData (comp: Label) { if (!comp.renderData) { return; } if (_comp === comp) { return; } if (comp.font instanceof BitmapFont) { _dpr = 1; } else { _dpr = getFontScaleFactor();//Math.min(Math.ceil(screenAdapter.devicePixelRatio), 2); } if (comp.renderData.vertDirty) { _comp = comp; _uiTrans = _comp.node._uiProps.uiTransformComp!; this._updateFontFamily(comp); this._updateProperties(comp); this._updateLabelInfo(comp); this._updateContent(); _comp.actualFontSize = _fontSize; _uiTrans.setContentSize(_contentSize); this.updateUVs(comp); _comp.renderData!.vertDirty = false; // fix bmfont run updateRenderData twice bug _comp.markForUpdateRenderData(false); _comp = null; this._resetProperties(); } if (comp.spriteFrame) { const renderData = comp.renderData; renderData.updateRenderData(comp, comp.spriteFrame); } } _updateProperties (comp) { _string = comp.string.toString(); _fontSize = comp.fontSize; _originFontSize = _fntConfig ? _fntConfig.fontSize : comp.fontSize; if (_enableDpr) _originFontSize *= _dpr; _hAlign = comp.horizontalAlign; _vAlign = comp.verticalAlign; _spacingX = comp.spacingX; _overflow = comp.overflow; _lineHeight = comp._lineHeight; const contentSize = _uiTrans!.contentSize; _contentSize.width = contentSize.width; _contentSize.height = contentSize.height; // should wrap text if (_overflow === Overflow.NONE) { _isWrapText = false; _contentSize.width += shareLabelInfo.margin * 2; _contentSize.height += shareLabelInfo.margin * 2; } else if (_overflow === Overflow.RESIZE_HEIGHT) { _isWrapText = true; _contentSize.height += shareLabelInfo.margin * 2; } else { _isWrapText = comp.enableWrapText; } shareLabelInfo.lineHeight = _lineHeight; shareLabelInfo.fontSize = _fontSize; this._setupBMFontOverflowMetrics(); } ``` > engine/cocos/2d/assembler/label/letter-font.ts ```typescript _updateFontFamily (comp) { this.enableDpr(true); shareLabelInfo.fontAtlas = _shareAtlas; shareLabelInfo.fontFamily = this._getFontFamily(comp); // outline const outline = comp.getComponent(LabelOutline); if (outline && outline.enabled) { shareLabelInfo.isOutlined = true; shareLabelInfo.margin = outline.width; shareLabelInfo.out = outline.color.clone(); shareLabelInfo.out.a = outline.color.a * comp.color.a / 255.0; } else { shareLabelInfo.isOutlined = false; shareLabelInfo.margin = 0; } } ``` ##优化过的测试用例 将cocos文本渲染修改为根据dpr进行渲染,包括none,bitmap和char模式都需要修改,并且保持接口层不变 ### dpr =1效果 ![3.4.1优化前效果dprx1](/api/file/getImage?fileId=622e828749ddfd5b2b000aa5) ![3.4.1优化后效果dprx1](/api/file/getImage?fileId=622e828749ddfd5b2b000a9c) ### dpr =2效果 ![3.4.1优化前效果dprx2](/api/file/getImage?fileId=622e828749ddfd5b2b000a9d) ![3.4.1优化后效果dprx2](/api/file/getImage?fileId=622e828749ddfd5b2b000a9f) ### 编辑器预览效果 ![3.4.1编辑器效果](/api/file/getImage?fileId=622e828749ddfd5b2b000aa4) ![优化后编辑器效果](/api/file/getImage?fileId=622e828749ddfd5b2b000aa0) ### 真机预览效果 ![](/api/file/getImage?fileId=622e828749ddfd5b2b000aa3) ![](/api/file/getImage?fileId=622e828749ddfd5b2b000a9e) 第一篇 apache2+fast-cgi+php的安装步骤
No Leanote account? Sign up now.