[译]离屏3D渲染:使用Canvas Worker 线程获得最佳性能

199 阅读13分钟

原文:Rendering 3d offscreen: Getting max performance using canvas workers

OffscreenCanvas Web API 使我们能够将 Canvas DOM 元素的所有权转移到 workers 中。

由于 Worker 在单独的线程内运行(如果可能的话,使用你自己的 CPU),这意味着昂贵的应用程序相关 JS 逻辑不会降低 Canvas 的渲染性能,反之亦然:Canvas 的昂贵逻辑不会影响应用程序的其他部分。

图像处理以及交互式图表或地图的渲染就是很好的用例。由于 Canvas 还支持 WebGL,因此使用离屏概念创建游戏引擎也很有意义。

1. 简介

Worker 无法访问 DOM → window 以及 window.document 是未定义的。

如果不使用该 API,要实现多线程操作就有点困难了。neo.mjs 项目很好地解决了这一问题:

neo.mjs架构.png neo.mjs 架构

使用应用程序 Worker 作为主要行为者,让主线程尽可能闲置。要实现这一点,虚拟 DOM 是必须的,因为你的应用程序(包括组件)就在应用程序 Worker 中。

要充分利用 OffscreenCanvas Web API,我们需要通过以下方式增强 Worker 设置:

image.png

"结合离屏画布和应用 Worker"也是这篇文章的合适标题,但我们还将介绍在此设置中创建一个基于 WebGL 的演示应用:

image.png neo-offscreen-canvas-demo

2.浏览器支持情况

Chromium(Chrome、Edge)已经很好地支持了新 API。

虽然兼容性表一开始看起来很吓人:

image.png 离屏Canvas兼容性

重要的是,Mozilla(Firefox)和 Webkit(Safari)团队正在积极推动这一议题。

Mozilla Bug 1390089 (offscreen-canvas)

估计:"最好在今年"

WebKit Bug 183720 Complete OffscreenCanvas implementation

"现在Webkit bug 224178 已经实现,我相信所有主要的 OffscreenCanvas 功能现在都已实现,并已在 Linux 平台上启用。当然,要在所有平台上启用该功能,还需要大量的工作来巩固和优化这一实现,但基础已经打好。万岁!"

我们需要时间来创造令人惊叹的实现,因此从现在开始是合理的。

3.与框架有关的改进

我们首先需要的是新的 Canvas Worker:

src/worker/Canvas.mjs

import Neo       from '../Neo.mjs';
import Base      from './Base.mjs';
import * as core from '../core/_export.mjs';

/**
 * Canvas Worker 负责动态操作离屏画布。
 * See: https://developer.mozilla.org/en-US/docs/Web/API/OffscreenCanvas
 * @class Neo.worker.Canvas
 * @extends Neo.worker.Base
 * @singleton
 */
class Canvas extends Base {
    static getConfig() {return {
        /**
         * @member {String} className='Neo.worker.Canvas'
         * @protected
         */
        className: 'Neo.worker.Canvas',
        /**
         * key: value => canvasId: OffscreenCanvas
         * @member {Object} map={}
         */
        map: {},
        /**
         * @member {Boolean} singleton=true
         * @protected
         */
        singleton: true,
        /**
         * @member {String} workerId='canvas'
         * @protected
         */
        workerId: 'canvas'
    }}

    /**
     *
     */
    afterConnect() {
        let me      = this,
            channel = new MessageChannel(),
            port    = channel.port2;

        channel.port1.onmessage = me.onMessage.bind(me);

        me.sendMessage('app', {action: 'registerPort', transfer: port}, [port]);

        me.channelPorts.app = channel.port1;
    }

    /**
     *
     * @param {Object} data
     */
    onRegisterCanvas(data) {
        this.map[data.nodeId] = data.node;

        Neo.currentWorker.sendMessage(data.origin, {
            action : 'reply',
            replyId: data.id,
            success: true
        });
    }

    /**
     *
     * @param {Object} msg
     */
    onRegisterNeoConfig(msg) {
        super.onRegisterNeoConfig(msg);

        let path = Neo.config.appPath.slice(0, -8); // removing "/app.mjs"

        import(
            /* webpackInclude: //canvas.mjs$/ */
            /* webpackExclude: //node_modules/ */
            /* webpackMode: "lazy" */
            `../../${path}/canvas.mjs`
        ).then(module => {
            module.onStart();
        });
    }
}

Neo.applyClassConfig(Canvas);

let instance = Neo.create(Canvas);

Neo.applyToGlobalNs(instance);

export default instance;

这里的关键部分是使用 afterConnect() 方法创建一个新的 MessageChannel ,在主应用程序和 Canvas Worker 之间建立直接连接(postMessage 无需通过主线程传递)。

我们将 Canvas 节点存储在 map 配置中,使用DOM (=== 组件 id) 作为键。我们还需要创建一个新的组件类:

src/component/Canvas.mjs

import Component from './Base.mjs';

/**
 * @class Neo.component.Canvas
 * @extends Neo.component.Base
 */
class Canvas extends Component {
    static getConfig() {return {
        /**
         * @member {String} className='Neo.component.Canvas'
         * @protected
         */
        className: 'Neo.component.Canvas',
        /**
         * @member {String} ntype='canvas'
         * @protected
         */
        ntype: 'canvas',
        /**
         * @member {Boolean} offscreen=true
         */
        offscreen: true,
        /**
         * 仅在 offscreen === true 时适用。
         * 当 Canvas 节点的所有权转移到 worker.Canvas 时为 true。
         * @member {Boolean} offscreenRegistered_=false
         */
        offscreenRegistered_: false,
        /**
         * @member {Object} _vdom={tag: 'canvas'}
         */
        _vdom:
        {tag: 'canvas'}
    }}

    /**
     * 在挂载的配置更改后触发
     * @param {Boolean} value
     * @param {Boolean} oldValue
     * @protected
     */
    afterSetMounted(value, oldValue) {
        super.afterSetMounted(value, oldValue);

        let me     = this,
            id     = me.getCanvasId(),
            worker = Neo.currentWorker;

        if (value && me.offscreen) {
            worker.promiseMessage('main', {
                action: 'getOffscreenCanvas',
                nodeId: id
            }).then(data => {
                worker.promiseMessage('canvas', {
                    action: 'registerCanvas',
                    node  : data.offscreen,
                    nodeId: id
                }, [data.offscreen]).then(() => {
                    me.offscreenRegistered = true;
                });
            });
        }
    }

    /**
     * 在使用包装器(例如 D3)时重写此方法
     * @returns {String}
     */
    getCanvasId() {
        return this.id;
    }
}

Neo.applyClassConfig(Canvas);

export {Canvas as default};

我们使用的是 offscreen 配置,其默认值为 true 。

挂载新的 Canvas 组件后,将触发 afterSetMounted() 方法。如果 offscreen 设置为 true,它将请求获得 Canvas 节点的所有权,并将其传递给画布 Worker(该 Worker 会将其存储在刚才提到的 map 中)。

之后,offscreenRegistered 配置将被设置为 true,这样我们就可以使用 afterSetOffscreenRegistered() 作为我们自己的组件实现的入口点。

任务完成。

4.嘉奖

由于我不想讲述如何使用 WebGL 的所有逻辑(跑题了),所以我一直在寻找一个漂亮且文档齐全的演示。

我很幸运!

克里斯-皮尔斯(Chris Pierce)的作品非常出色,我们要向他致敬!

使用 OffscreenCanvas 渲染图表

虽然这篇文章已经发表了一年,但它在很多方面仍然适用。

我非常不同意的一点是使用多个 MessageChannels 来覆盖不同的消息类别。在 postMessages 的基础上使用自己的 API 才是正确的做法,并已在 neo.mjs 项目中实现。

你可以在这里找到克里斯的演示仓库:

chrisprice/offscreen-canvas: 使用离屏画布的图表渲染示例

5.创建基于 neo.mjs 的演示应用程序

我首先使用 CLI 生成了一个新的工作区:npx neo-app

你可以在这里找到最终结果:

neomjs/offscreen-canvas: Demo app using worker.Canvas

由于新的 canvas worker 是可选的,因此我们需要做的第一件事就是在 neo-config.json 文件中激活它:

{
    "appPath": "../../apps/myapp/app.mjs",
    "basePath": "../../",
    "environment": "development",
    "mainPath": "../node_modules/neo.mjs/src/Main.mjs",
    "themes": ["neo-theme-light"],
    "useCanvasWorker": true,
    "useFontAwesome": false,
    "workerBasePath": "../../node_modules/neo.mjs/src/worker/"
}

框架使用 app.mjs 文件作为应用程序 Worker 的入口点:

import MainContainer from './view/MainContainer.mjs';

const onStart = () => Neo.app({
    appPath : 'apps/myapp/',
    mainView: MainContainer,
    name    : 'MyApp'
});

export {onStart as onStart};

因此,使用以 canvas.mjs 命名的文件作为新 canvas Worker 的起点是合理的:

import Helper from './canvas/Helper.mjs';

const onStart = () => {
    console.log(Helper);
};

export {onStart as onStart};

这个文件还使用了 onStart() 方法,允许你在 Worker 就绪后触发逻辑。

让我们从 MainContainer.mjs 视图开始:

import Label          from '../../../node_modules/neo.mjs/src/component/Label.mjs';
import Toolbar        from '../../../node_modules/neo.mjs/src/container/Toolbar.mjs';
import Viewport       from '../../../node_modules/neo.mjs/src/container/Viewport.mjs';
import WebGlComponent from './WebGlComponent.mjs';

/**
 * @class MyApp.view.MainContainer
 * @extends Neo.container.Viewport
 */
class MainContainer extends Viewport {
    static getConfig() {return {
        /**
         * @member {String} className='MyApp.view.MainContainer'
         * @protected
         */
        className: 'MyApp.view.MainContainer',
        /**
         * @member {Boolean} autoMount=true
         * @protected
         */
        autoMount: true,
        /**
         * @member {Object} layout={ntype:'vbox',align:'stretch'}
         */
        layout: {ntype: 'vbox', align: 'stretch'}
    }}

    /**
     * @param {Object} config
     */
    constructor(config) {
        super(config);

        let me = this;

        me.items = [{
            ntype : 'container',
            flex  : 1,
            items : [WebGlComponent],
            layout: {ntype: 'fit'},
            vdom  : {tag: 'd3fc-group', 'auto-resize': true, cn: []}
        }, {
            module: Toolbar,
            flex  : 'none',
            items : [{
                handler: me.onStopAnimationButtonClick.bind(me),
                text   : 'Stop Animation'
            }, {
                handler: me.onStopMainButtonClick.bind(me),
                style  : {marginLeft: '.2em'},
                text   : 'Stop Main'
            }, {
                handler    : me.changeItemAmount.bind(me, 10000),
                pressed    : true,
                style      : {marginLeft: '2em'},
                text       : `${(10000).toLocaleString()} items`,
                toggleGroup: 'itemAmount',
                value      : 10000
            }, {
                handler    : me.changeItemAmount.bind(me, 100000),
                style      : {marginLeft: '.2em'},
                text       : `${(100000).toLocaleString()} items`,
                toggleGroup: 'itemAmount',
                value      : 100000
            }, {
                handler    : me.changeItemAmount.bind(me, 1000000),
                style      : {marginLeft: '.2em'},
                text       : `${(1000000).toLocaleString()} items`,
                toggleGroup: 'itemAmount',
                value      : 1000000
            }, {
                module   : Label,
                reference: 'time-label',
                style    : {marginLeft: '2em'},
                text     : `Time: ${me.getTime()}`
            }]
        }];
    }

    /**
     * 在挂载的配置更改后触发
     * @param {Boolean} value
     * @param {Boolean} oldValue
     * @protected
     */
    afterSetMounted(value, oldValue) {
        super.afterSetMounted(value, oldValue);

        if (value) {
            setInterval(() => {
                this.down({reference: 'time-label'}).text = `Time: ${this.getTime()}`;
            }, 1000);
        }
    }

    /**
     * @param {Number} count
     */
    changeItemAmount(count) {
        let me = this;

        MyApp.canvas.Helper.changeItemsAmount(count);

        me.items[1].items.forEach(item => {
            if (item.toggleGroup === 'itemAmount') {
                item.pressed = item.value === count;
            }
        });
    }

    getTime() {
        return new Date().toLocaleString(Neo.config.locale, {
            hour  : '2-digit',
            minute: '2-digit',
            second: '2-digit'
        });
    }

    /**
     * @param {Object} data
     */
    onStopAnimationButtonClick(data) {
        let enableAnimation = true,
            buttonText;

        if (data.component.text === 'Stop Animation') {
            buttonText      = 'Start Animation';
            enableAnimation = false;
        } else {
            buttonText = 'Stop Animation';
        }

        data.component.text = buttonText;

        MyApp.canvas.Helper.enableAnimation(enableAnimation);
    }

    /**
     * @param {Object} data
     */
    onStopMainButtonClick(data) {
        Neo.Main.alert([
            'This alert pauses the JS main thread.\n\n',
            'Notice that the time inside the bottom toolbar has stopped updating.\n\n',
            'Closing this alert will resume the main thread.'
        ].join(''));
    }
}

Neo.applyClassConfig(MainContainer);

export {MainContainer as default};

我们使用的是垂直框 (vbox) 布局的视口。

由于我们使用的是 d3,因此我们将自定义 WebGlComponent 映射到一个容器(自定义 d3-fc 标记)中,该容器负责根据需要调整画布节点的大小。

我们将在底部放置一个包含多个按钮的工具栏。

你会注意到一些 MyApp.canvas.Helper.* 方法被触发。这是基于远程方法访问(remotes API),我们稍后将对其进行更深入的介绍。

onStopMainButtonClick() 将触发 Neo.Main.alert() 方法,因为 Worker 无法自行调用 alert() 方法。这也是一个远程方法。显示警报将停止主线程相关的 JS 执行。但是,它不会停止与 UI 相关的渲染线程,因此我们的画布 Worker 仍可继续动画画布节点。

6.创建自定义 WebGlComponent

这只需要几行代码:

import Canvas from '../../../node_modules/neo.mjs/src/component/Canvas.mjs';

/**
 * @class MyApp.view.WebGlComponent
 * @extends Neo.component.Canvas
 */
class WebGlComponent extends Canvas {
    static getConfig() {return {
        /**
         * @member {String} className='MyApp.view.WebGlComponent'
         * @protected
         */
        className: 'MyApp.view.WebGlComponent',
        /**
         * @member {Object} _vdom
         */
        _vdom:
        {tag: 'd3fc-canvas', cn: [
            {tag: 'canvas'}
        ]}
    }}

    /**
     * 在id配置更改后触发
     * @param {String} value
     * @param {String} oldValue
     */
    afterSetId(value, oldValue) {
        let me = this;

        me.vdom.cn[0].id = `${value}__canvas`;

        super.afterSetId(value, oldValue);
    }

    /**
     * 在 offscreenRegistered 配置更改后触发
     * @param {Boolean} value
     * @param {Boolean} oldValue
     * @protected
     */
    afterSetOffscreenRegistered(value, oldValue) {
        if (value) {
            let me           = this,
                domListeners = me.domListeners;

            domListeners.push(
                {measure: me.onMeasure, scope: me} // 自定义 d3fc DOM 事件
            );

            me.domListeners = domListeners;

            // 我们需要短暂的延迟,以确保我们的基于应用程序的远程方法在分发环境中注册。
            setTimeout(() => {
                // 远程方法访问画布Worker线程
                MyApp.canvas.Helper.renderSeries(this.getCanvasId());

                Neo.main.DomAccess.getBoundingClientRect({id: me.id}).then(rect => {
                    me.updateSize(rect.height, rect.width);
                });
            }, 100);
        }
    }

    /**
     * 在使用包装器(例如 D3)时重写此方法
     * @returns {String}
     */
    getCanvasId() {
        return this.vdom.cn[0].id;
    }

    /**
     * @param {Object} data
     */
    onMeasure(data) {
        let node = data.path[0];
        this.updateSize(node.clientHeight, node.clientWidth);
    }

    /**
     * @param {Number} height
     * @param {Number} width
     */
    updateSize(height, width) {
        MyApp.canvas.Helper.updateSize({ height, width });
    }
}

Neo.applyClassConfig(WebGlComponent);

export {WebGlComponent as default};

d3 再次要求我们创建一个自定义封装节点(d3fc-canvas 标签)。

我们使用该节点来应用自定义的 d3 measure domListener,因此我们可以将 vdom 根保留在顶层。

不过,我们仍然需要为内部画布节点设置一个唯一 ID,因为我们要使用它来请求所有权转移。我在 component.Canvas 层实现了 getCanvasId() 方法,因此我们只需在此处指向第一个子节点即可。

7.查看 canvas.Helper 类

希望你还记得,我们的 canvas.mjs 入口点导入了 canvas.Helper 。因此,该文件(单例)将在 canvas Worker 中运行。

import Base from '../../../node_modules/neo.mjs/src/core/Base.mjs';

/**
 * @class MyApp.canvas.Helper
 * @extends Neo.core.Base
 * @singleton
 */
class Helper extends Base {
    /**
     * @member {String|null} canvasId=null
     */
    canvasId = null
    /**
     * 包含高度和宽度属性
     * @member {Object} canvasSize=null
     */
    canvasSize = null
    /**
     * @member {Object[]|null} data=null
     */
    data = null
    /**
     * @member {Function|null} series=null
     */
    series = null
    /**
     * @member {Function|null} xScale=null
     */
    xScale = null
    /**
     * @member {Function|null} yScale=null
     */
    yScale = null

    static getConfig() {return {
        /**
         * @member {String} className='MyApp.canvas.Helper'
         * @protected
         */
        className: 'MyApp.canvas.Helper',
        /**
         * @member {Boolean} singleton=true
         * @protected
         */
        singleton: true,
        /**
         * @member {Number} itemsAmount_=10000
         */
        itemsAmount_: 10000,
        /**
         * 其他Worker的远程方法访问
         * @member {Object} remote
         * @protected
         */
        remote: {
            app: [
                'changeItemsAmount',
                'enableAnimation',
                'renderSeries',
                'updateSize'
            ]
        },
        /**
         * @member {Boolean} stopAnimation_=false
         */
        stopAnimation_: false
    }}

    /**
     * @param {Object} config
     */
    constructor(config) {
        super(config);

        let me = this;

        me.promiseImportD3().then(() => {
            me.xScale = d3.scaleLinear().domain([-5, 5]);
            me.yScale = d3.scaleLinear().domain([-5, 5]);

            me.generateData();
            me.generateSeries();

            // 如果 d3 脚本在Canvas所有权转移后加载,我们需要触发之前被阻止的逻辑。
            if (me.canvasId) {
                me.renderSeries(me.canvasId);
                me.updateSize(me.canvasSize);
            }
        });
    }

    /**
     * 在 itemsAmount 配置更改后触发
     * @param {Number} value
     * @param {Number} oldValue
     */
    afterSetItemsAmount(value, oldValue) {
        if (value && Neo.isNumber(oldValue)) {
            let me = this;

            me.stopAnimation = true;

            me.generateData();
            me.generateSeries();
            me.renderSeries(me.canvasId, true);

            me.stopAnimation = false;
        }
    }

    /**
     * 在 stopAnimation 配置更改后触发
     * @param {Boolean} value
     * @param {Boolean} oldValue
     */
    afterSetStopAnimation(value, oldValue) {
        if (!value && Neo.isBoolean(oldValue)) {
            this.render();
        }
    }

    /**
     * @param {Number} count
     */
    changeItemsAmount(count) {
        this.itemsAmount = count;
    }

    /**
     * @param {Boolean} enable
     */
    enableAnimation(enable) {
        this.stopAnimation = !enable;
    }

    /**
     *
     */
    generateData() {
        let randomNormal    = d3.randomNormal(0, 1),
            randomLogNormal = d3.randomLogNormal();

        this.data = Array.from({ length: this.itemsAmount }, () => ({
            x   : randomNormal(),
            y   : randomNormal(),
            size: randomLogNormal() * 10
        }));
    }

    /**
     *
     */
    generateSeries() {
        let me         = this,
            colorScale = d3.scaleOrdinal(d3.schemeAccent),

            series = fc
                .seriesWebglPoint()
                .xScale(me.xScale)
                .yScale(me.yScale)
                .crossValue(d => d.x)
                .mainValue(d => d.y)
                .size(d => d.size)
                .equals(previousData => previousData.length > 0),

            webglColor = color => {
                let { r, g, b, opacity } = d3.color(color).rgb();
                return [r / 255, g / 255, b / 255, opacity];
            },

            fillColor = fc
                .webglFillColor()
                .value((d, i) => webglColor(colorScale(i)))
                .data(me.data);

        series.decorate(program => fillColor(program));

        me.series = series;
    }

    /**
     * 动态导入所有与 d3 相关的依赖项
     * @returns {Promise<resolve>}
     */
    async promiseImportD3() {
        let imports = [
                () => import('../../../node_modules/d3-array/dist/d3-array.js'),
                () => import('../../../node_modules/d3-color/dist/d3-color.js'),
                () => import('../../../node_modules/d3-format/dist/d3-format.js'),
                () => import('../../../node_modules/d3-interpolate/dist/d3-interpolate.js'),
                () => import('../../../node_modules/d3-scale-chromatic/dist/d3-scale-chromatic.js'),
                () => import('../../../node_modules/d3-random/dist/d3-random.js'),
                () => import('../../../node_modules/d3-scale/dist/d3-scale.js'),
                () => import('../../../node_modules/d3-shape/dist/d3-shape.js'),
                () => import('../../../node_modules/d3-time-format/dist/d3-time-format.js'),
                () => import('../../../node_modules/@d3fc/d3fc-extent/build/d3fc-extent.js'),
                () => import('../../../node_modules/@d3fc/d3fc-random-data/build/d3fc-random-data.js'),
                () => import('../../../node_modules/@d3fc/d3fc-rebind/build/d3fc-rebind.js'),
                () => import('../../../node_modules/@d3fc/d3fc-series/build/d3fc-series.js'),
                () => import('../../../node_modules/@d3fc/d3fc-webgl/build/d3fc-webgl.js')
            ],

            modules = [],
            i       = 0,
            len     = imports.length,
            item;

        for (; i < len; i++) {
            item = await imports[i]();
            modules.push(item);
        }

        // Bug:在基于webpack的发布环境中,d3fc 会将其函数复制到模块中,而不是将它们放入全局fc命名空间。

        // 这个 hack 解决了这个问题。
        if (!self.fc) {
            self.fc = {};

            modules.forEach(item => {
                if (Object.keys(item).length > 0) {
                    Object.assign(self.fc, item);
                }
            });
        }

        return Promise.resolve();
    }

    /**
     *
     */
    render() {
        let me   = this,
            ease = 5 * (0.51 + 0.49 * Math.sin(Date.now() / 1e3));

        if (!me.stopAnimation) {
            me.xScale.domain([-ease, ease]);
            me.yScale.domain([-ease, ease]);

            me.series(me.data);

            requestAnimationFrame(me.render.bind(me));
        }
    }

    /**
     * @param {String} canvasId
     * @param {Boolean} silent=false
     */
    renderSeries(canvasId, silent=false) {
        let me = this,
            webGl;

        me.canvasId = canvasId;

        if (me.series) {
            webGl = Neo.currentWorker.map[canvasId].getContext('webgl');

            me.series.context(webGl);
            !silent && me.render();
        }
    }

    /**
     * @param {Object} data
     * @param {Number} data.height
     * @param {Number} data.width
     */
    updateSize(data) {
        let me = this;

        me.canvasSize = data;

        if (me.series) {
            let webGl = me.series.context();

            Object.assign(webGl.canvas, {
                height: data.height,
                width : data.width
            });

            webGl.viewport(0, 0, webGl.canvas.width, webGl.canvas.height);
        }
    }
}

Neo.applyClassConfig(Helper);

let instance = Neo.create(Helper);

Neo.applyToGlobalNs(instance);

export default instance;

这里最重要的一点是第 55 行:我们将在 remote 配置中添加一些类方法名称。

这将在目标 Worker 中注册命名空间,然后允许我们在新作用域中以承诺的形式调用相同的命名空间和函数名。

如果你想知道

MyApp.canvas.Helper.enableAnimation(enableAnimation);

是什么,这就是答案。

在底层,它将从应用程序 Worker 向 Canvas Worker 发送 postMessage 消息,触发相关方法并发送包含返回值的回复 postMessage 消息。

由于我们的方法是一个 Promise,因此我们可以使用 then() 作为回调,或者使用 async 和 await 。

导入 d3 和 d3fc 文件后,我们将遵循 Chris Scott 演示应用程序的逻辑。生成数据、序列,一旦画布节点的所有权到达,我们就可以渲染序列。

与原始演示不同的是,更改项目(点)的数量不需要重新加载整个应用程序(页面),而是动态生成一个新的画布系列。我还添加了停止/重新启用动画功能。

Worker 拥有自己的 requestAnimationFrame() 实现,这非常方便。我认为 OffscreenCanvas 是它的第一个也是唯一一个用例。

8.在浏览器中直接使用 d3

这可能是我花时间最多的项目。

请看克里斯演示:

image.png

【题外话】 d3-collection 已被弃用 → 不再需要。

在使用基于 JS 模块的 Worker 时,不可能/不允许使用 importScripts() 。

new Worker('App.mjs', {type: 'module'})

我首先尝试的是

image.png

这在开发模式下(即直接在浏览器中运行,无需任何构建或转译)运行得非常好。

不过,我还想让它在基于 webpack 的 dist/development 和 dist/production 环境中运行。

使用静态导入会产生一个巨大的分割块,对我来说并不起作用(JS 运行时出错)。

要解决分块问题,我们需要改用动态导入。

d3 的导入方式非常简单,因为每个文件基本上都是一个嵌套函数,它会立即触发一个内部函数。每个内部函数都要求前一个导入已经存在。

作为一种解决方案,我们确实需要动态导入,将其作为一个序列来处理。因此,我在 canvas.Helper 文件中添加了以下逻辑:

async promiseImportD3() {
    let imports = [
            () => import('../../../node_modules/d3-array/dist/d3-array.js'),
            () => import('../../../node_modules/d3-color/dist/d3-color.js'),
            () => import('../../../node_modules/d3-format/dist/d3-format.js'),
            () => import('../../../node_modules/d3-interpolate/dist/d3-interpolate.js'),
            () => import('../../../node_modules/d3-scale-chromatic/dist/d3-scale-chromatic.js'),
            () => import('../../../node_modules/d3-random/dist/d3-random.js'),
            () => import('../../../node_modules/d3-scale/dist/d3-scale.js'),
            () => import('../../../node_modules/d3-shape/dist/d3-shape.js'),
            () => import('../../../node_modules/d3-time-format/dist/d3-time-format.js'),
            () => import('../../../node_modules/@d3fc/d3fc-extent/build/d3fc-extent.js'),
            () => import('../../../node_modules/@d3fc/d3fc-random-data/build/d3fc-random-data.js'),
            () => import('../../../node_modules/@d3fc/d3fc-rebind/build/d3fc-rebind.js'),
            () => import('../../../node_modules/@d3fc/d3fc-series/build/d3fc-series.js'),
            () => import('../../../node_modules/@d3fc/d3fc-webgl/build/d3fc-webgl.js')
        ],

        modules = [],
        i       = 0,
        len     = imports.length,
        item;

    for (; i < len; i++) {
        item = await imports[i]();
        modules.push(item);
    }

    // Bug: Inside the webpack based dist envs, d3fc will copy its function to the module,
    // instead of putting them into the global fc namespace.
    // This hack resolves it.
    if (!self.fc) {
        self.fc = {};

        modules.forEach(item => {
            if (Object.keys(item).length > 0) {
                Object.assign(self.fc, item);
            }
        });
    }

    return Promise.resolve();
}

【题外话】为了减小文件大小,我主要使用了 "let" 和逗号,而不是 "const"。

将每次导入都封装到一个函数中非常重要,例如 () => import() 以确保导入不会立即执行。然后,我们可以使用 await 来处理每一个导入。这样做肯定会慢一些,但我们别无选择。

虽然 d3 可以将其函数添加到全局的 d3 命名空间,但 d3fc 库实际上并不是这样。在 dist envs 中,它只是将函数添加到模块中,因此我们需要手动修复。我很快就会创建一份错误报告。

9.演示视频

演示视频

最后还有一个好消息:

我们在控制台中记录了 canvas worker Helper 类。

我们可以直接在这里更改配置,用户界面会自动调整。

10.在线演示

请注意,这些演示目前只能在基于 Chromium 的浏览器中运行。

开发模式(按源码运行代码): neomjs.github.io/pages2/work…

dist/production(基于 webpack): neomjs.github.io/pages2/work…

我注意到Canvas动画在开发模式下运行得更加流畅,尤其是在运行到 100 万个点时。

但我并不完全确定原因。我的猜测是,webpack 为访问每个模块创建了封装函数→总共调用了更多的函数,这些函数的调用次数加起来确实很多。

点击 "stop main" 按钮会创建一个 alert() 来暂停计时器,而我们的画布会继续动画。

11.最后的想法

如果你还没有时间深入研究 neo.mjs 项目,那就真的应该去看看了:

neomjs/neo: Worker 驱动的前端框架

新的 Canvas worker 标志着 v2.3 版本的发布(我将很快调整版本号)。

完成日历将被推入 v2.4(已经非常接近了)。

现在我们已经具备了在 neo 项目中创建图像、图表或游戏库的基本设置。

如果有人愿意参与这部分工作,我们将不胜感激!

我期待看到使用新的 OffscreenCanvas Worker 构建的演示或实际应用!

致以最诚挚的问候,祝你编码愉快,
托比亚斯