若干年前,为了看一些已经下架的老番的弹幕(主要是Q娃),写过一个弹幕转字幕的工具
https://github.com/otakustay/danmaku-to-ass现在回头看看,代码的质量不怎么样,不过有一些算法是可以用的
弹幕的移动
弹幕的基本属性就2个,内容+出现时间
弹幕的移动是一个标准的线性动画,请不要放飞自我地使用任何其它缓动函数,变速的弹幕会给大脑和眼睛带来很大的负担,短命几年你是要负责的
但是弹幕的移动是等时不等速的,简单来说,弹幕的动画起始时间为文字左侧切入屏幕的时间,结束时间为文字右侧移出屏幕的时间。这个算法的结果就是弹幕根据它的内容长短有不同的速度,会显得更加“错落有致”
代价是特别长的弹幕移动速度会非常快,可以从弹幕长度上做一些限制
弹幕去重
去重最简单的方法当然是整个弹幕列表里一样的全部只保留一份。但是这并不合理,一个好的动画,在OP的时候让人喊一声“卧槽”,在ED的时候再让人喊一声“卧槽”是完全可能的,所以不能单纯地使用列表进行静态的过滤
弹幕去重的真正目的是让用户不会在同一屏上不断阅读重复的信息,所以优化一下算法,比较简单的是从一条弹幕出现开始,一定时间内的同内容弹幕全部删除。这个算法就基本可用了,这个时间建议取弹幕的动画时长,这个算法在这边:
// 当一个弹幕出现时,记录这个弹幕的时间,如果后续有和这个弹幕一样的出现,且在`timespan`的时间内,那这条弹幕就直接丢掉了, // 如果超过了规定的时间,这条弹幕是允许出现的,然后更新时间 export let mergeDanmaku = (list, timespan) => { if (timespan < 0) { return list; } let mergeContext = list.reduce( ([result, cache], danmaku) => { let lastAppearTime = cache[danmaku.content]; if (danmaku.time - lastAppearTime <= timespan) { console.log(`[Info] Merged ${danmaku.time}: ${danmaku.content}`); return [result, cache]; } cache[danmaku.content] = danmaku.time; result.push(danmaku); return [result, cache]; }, [[], {}] ); return mergeContext[0]; };
值得注意的是,对弹幕进行去重可以增加弹幕的内容有效性,但是再也不会有“欧拉 + 木大”这种弹幕奇观的出现了(奇观误国),这里需要取舍,做成可配置的更好
防碰撞
防碰撞其实不是难事,算法写在这里了,配上注释大致能看懂:
import Canvas from 'canvas'; import {DanmakuType, FontSize} from '../enum'; import {arrayOfLength} from './lang'; // 计算一个矩形移进屏幕的时间(头进屏幕到尾巴进屏幕) let computeScrollInTime = (rectWidth, screenWidth, scrollTime) => { let speed = (screenWidth + rectWidth) / scrollTime; return rectWidth / speed; }; // 计算一个矩形在屏幕上的时间(头进屏幕到头离开屏幕) let computeScrollOverTime = (rectWidth, screenWidth, scrollTime) => { let speed = (screenWidth + rectWidth) / scrollTime; return screenWidth / speed; }; let splitGrids = ({fontSize, padding, playResY, bottomSpace}) => { let defaultFontSize = fontSize[FontSize.NORMAL]; let paddingTop = padding[0]; let paddingBottom = padding[2]; let linesCount = Math.floor((playResY - bottomSpace) / (defaultFontSize + paddingTop + paddingBottom)); // 首先以通用的字号把屏幕的高度分成若干行,字幕只允许落在一个行里 return { // 每一行里的数字是当前在这一行里的最后一条弹幕区域(算入padding)的右边离开屏幕的时间, // 这个时间和下一条弹幕的左边离开屏幕的时间相比较,能确定在整个弹幕的飞行过程中是否会相撞(不同长度弹幕飞行速度不同), // 当每一条弹幕加到一行里时,就会把这个时间算出来,获取新的弹幕时就可以判断哪一行是允许放的就放进去 [DanmakuType.SCROLL]: arrayOfLength(linesCount, {start: 0, end: 0, 0}), // 对于固定的弹幕,每一行里都存放弹幕的消失时间,只要这行的弹幕没消失就不能放新弹幕进来 [DanmakuType.TOP]: arrayOfLength(linesCount, 0), [DanmakuType.BOTTOM]: arrayOfLength(linesCount, 0) }; }; let measureTextWidth = (() => { let canvasContext = (new Canvas()).getContext('2d'); let supportTextMeasure = !!canvasContext.measureText('中'); if (supportTextMeasure) { return (fontName, fontSize, bold, text) => { canvasContext.font = `${bold ? 'bold' : 'normal'} ${fontSize}px ${fontName}`; let textWidth = canvasContext.measureText(text).width; return Math.round(textWidth); }; } console.warn('[Warn] node-canvas is installed without text measure support, layout may not be correct'); return (fontName, fontSize, bold, text) => text.length * fontSize; })(); // 找到能用的行 let resolveAvailableFixGrid = (grids, time) => { for (let i = 0; i < grids.length; i++) { if (grids[i] <= time) { return i; } } return -1; }; let resolveAvailableScrollGrid = (grids, rectWidth, screenWidth, time, duration) => { for (let i = 0; i < grids.length; i++) { let previous = grids[i]; // 对于滚动弹幕,要算两个位置: // // 1. 前一条弹幕的尾巴进屏幕之前,后一条弹幕不能开始出现 // 2. 前一条弹幕的尾巴离开屏幕之前,后一条弹幕的头不能离开屏幕 let previousInTime = previous.start + computeScrollInTime(previous.width, screenWidth, duration); let currentOverTime = time + computeScrollOverTime(rectWidth, screenWidth, duration); if (time >= previousInTime && currentOverTime >= previous.end) { return i; } } return -1; }; let initializeLayout = config => { let {playResX, playResY, fontName, fontSize, bold, padding, scrollTime, fixTime, bottomSpace} = config; let [paddingTop, paddingRight, paddingBottom, paddingLeft] = padding; let defaultFontSize = fontSize[FontSize.NORMAL]; let grids = splitGrids(config); let gridHeight = defaultFontSize + paddingTop + paddingBottom; return danmaku => { let targetGrids = grids[danmaku.type]; let danmakuFontSize = fontSize[danmaku.fontSizeType]; let rectWidth = measureTextWidth(fontName, danmakuFontSize, bold, danmaku.content) + paddingLeft + paddingRight; let verticalOffset = paddingTop + Math.round((defaultFontSize - danmakuFontSize) / 2); let gridNumber = danmaku.type === DanmakuType.SCROLL ? resolveAvailableScrollGrid(targetGrids, rectWidth, playResX, danmaku.time, scrollTime) : resolveAvailableFixGrid(targetGrids, danmaku.time); if (gridNumber < 0) { console.warn(`[Warn] Collision ${danmaku.time}: ${danmaku.content}`); return null; } if (danmaku.type === DanmakuType.SCROLL) { targetGrids[gridNumber] = { rectWidth, start: danmaku.time, end: danmaku.time + scrollTime}; let top = gridNumber * gridHeight + verticalOffset; let start = playResX + paddingLeft; let end = -rectWidth; return {...danmaku, top, start, end}; } else if (danmaku.type === DanmakuType.TOP) { targetGrids[gridNumber] = danmaku.time + fixTime; let top = gridNumber * gridHeight + verticalOffset; // 固定弹幕横向按中心点计算 let left = Math.round(playResX / 2); return {...danmaku, top, left}; } targetGrids[gridNumber] = danmaku.time + fixTime; // 底部字幕的格子是留出`bottomSpace`的位置后从下往上算的 let top = playResY - bottomSpace - gridHeight * gridNumber - gridHeight + verticalOffset; let left = Math.round(playResX / 2); return {...danmaku, top, left}; }; }; export let layoutDanmaku = (inputList, config) => { let list = [].slice.call(inputList).sort((x, y) => x.time - y.time); let layout = initializeLayout(config); return list.map(layout).filter(danmaku => !!danmaku); };
简单来说,一个弹幕的内容加上固定的字体和样式,就能得出一个固定的矩形。用canvas的measureText就可以算出来了
然后因为弹幕的运动时长是固定的,加上屏幕的宽度和弹幕本身的宽度,可以算出移动的速度
在知道了移动的速度的之后,配合弹幕出现的时间,是可以算出任意一个时间点,这个弹幕在屏幕上占用的矩形位置的
后续要做的,就是新的弹幕生成的时候避开这个矩形位置就行,这个算法可以进一步简化
把屏幕上部按弹幕的高度分成若干个行,任一时间,从第一行往下开始计算,第一个最右侧空间(保留一定安全距离)没被占用的行是可能可以放置新弹幕的
在这个基础上,还要做一个运算,因为弹幕是不等速的,所以要保证新的弹幕的左侧移动到屏幕左侧时,这一行上前面所有弹幕都已经消失,这也可以简单地用弹幕的速度做出计算
如果所有的行都放不下,这条新的弹幕直接丢弃就好。一般我们取屏幕的上30%供弹幕使用,不会占用全部空间,这个也可以根据需求调整
这些算法都是在弹幕出现以前就能算出来的,只要有一个弹幕的列表,再知道每一条弹幕的宽度(可以近似地测量单个中文和英文的宽度,不精确没关系)甚至可以知道任何一毫秒时弹幕的分布。所以不需要在弹幕运动过程中做额外的计算,只要用CSS动画等方式保持住弹幕移动时的FPS,本身不会有什么性能问题(当然那时候生成的字幕拿去小米盒子上跑,当场就卡死了,毕竟性能太差)