• 弹幕的三个关键要点


    若干年前,为了看一些已经下架的老番的弹幕(主要是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,  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,本身不会有什么性能问题(当然那时候生成的字幕拿去小米盒子上跑,当场就卡死了,毕竟性能太差)

  • 相关阅读:
    第四周编程总结
    第三周编程总结
    第二周编程总结
    第一周编程总结
    2019年寒假作业3
    2019年寒假作业2
    2019年寒假作业1
    第七周编程总结
    第六周编程总结
    第五周编程总结
  • 原文地址:https://www.cnblogs.com/smedas/p/12788153.html
Copyright © 2020-2023  润新知