弹幕的三个关键要点

若干年前,为了看一些已经下架的老番的弹幕(主要是Q娃),写过一个弹幕转字幕的工具

https://github.com/otakustay/danmaku-to-ass​github.com

现在回头看看,代码的质量不怎么样,不过有一些算法是可以用的

弹幕的移动

弹幕的基本属性就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, width: 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] = {width: 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,本身不会有什么性能问题(当然那时候生成的字幕拿去小米盒子上跑,当场就卡死了,毕竟性能太差)

猜你喜欢

转载自www.cnblogs.com/smedas/p/12788153.html
今日推荐