OffScreenCanvas和触摸事件

时间:2020-05-10 16:51:36

标签: javascript canvas html5-canvas offscreen-canvas

我正在尝试将新的API用于OffScreenCanvas。这个想法是为了移动有关绘制和更新播放器数据的所有逻辑,但在主线程中保留一些其他逻辑,例如触摸事件(因为工作程序无法到达窗口对象)。

所以我上了Player.js

export class Player {
  constructor() {
    this.width = 100;
    this.height = 100;
    this.posX = 0;
    this.posY = 0;
  }

  draw = (ctx) => {
    ctx.fillRect(this.posX, this.posY, this.width, this.height);
  }

  updatePos = (x, y) => {
    this.posX = x;
    this.posY = y;
  }
}

我在名为playerObject.js的另一个模块中生成播放器的实例

import { Player } from "./Player.js";

export const player = new Player();

OffScreenCanvas就是这样创建的

const canvas = document.querySelector('#myGame');
const offscreen = canvas.transferControlToOffscreen();
const worker = new Worker('offscreencanvas.js', { type: "module" });
worker.postMessage({ canvas: offscreen }, [offscreen]);

现在我将playerObject导入到OffScreenCanvas worker

import {player} from "./playerObject.js";

addEventListener('message', (evt) => {
  const canvas = evt.data.canvas;
  const ctx = canvas.getContext("2d");

  const render = () => {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    player.draw(ctx);

    requestAnimationFrame(render);
  }

  render();
});

以及包含触摸事件的类(模块),这些事件正在更改玩家的位置:

import {player} from "./playerObject.js";

export class Game {
  constructor() {
    this.touch();
  }

  touch = () => {
    window.addEventListener('touchstart', (e) => {
      player.updatePos(e.touches[0].clientX, e.touches[0].clientY);

    }, {passive: true});
  }
}

问题是OffScreenCanvas看不到Game类所做的更改。触摸本身可以正常工作(console.log显示事件,还可以修改播放器对象),但是在OffScreenCanvas中,播放器仍具有初始坐标。

我仍然不确定那里发生了什么。工作人员是否正在创建类的新实例,这就是为什么它没有看到来自触摸事件的更改?

有没有办法做到这一点?

2 个答案:

答案 0 :(得分:1)

您当前有两个不同的Player实例,它们不会彼此通信。

There is a proposal是用于允许将一些事件从主线程传递到Worker线程的粗略草稿,但它实际上仍然只是草稿,我不确定什么时候会到来,也不清楚完全成型。

当前,我们唯一的解决方案是在主线程和辅助线程之间建立自己的桥梁,以便主线程在发生事件时将所有事件发送给辅助线程。
这有很多缺点,其中

  • 明显的延迟,因为我们必须等待主线程接收事件,然后才分派新的MessageEvent任务,
  • 对主线程的依赖:必须自由处理事件,这意味着即使在主线程被锁定的情况下,OffscreenCanvas也可以平稳运行的承诺在这里被破坏了。
  • 难以维护(?)由于我们没有清晰的API来访问我们要获得的目标,因此我们必须在主线程和工作线程中均采用难看的硬编码值

但是,我们仍然可以取得成就。

我只是花了一些时间根据我链接的当前提案编写了一个粗略的玩具,松散地,做了一些更改,没有进行测试,因此可以以此为基础来编写您自己的,但不要期望它在每种情况下都能无缝运行。

基本逻辑是

  • 在主线程中,我们增强了Worker接口,以使其与Worker线程(通过MessageChannel对象)启动专用通信通道。
  • 我们还向该接口添加了addEventTarget( target, uuid )方法。
  • 当从主线程的脚本中收到消息时,我们将从Worker线程初始化一个接收器。从那里,我们持有MessageChannel并等待,直到它说出从主线程声明了新的 delegatedTargets 为止。
  • 发生这种情况时,我们将触发一个新事件 eventtargetadded ,供工作线程中运行的用户脚本收听,并暴露一个 EventDelegate 实例,该实例将创建一个新的private Worker和主线程之间的通信通道。
  • 通过此EventDelegate的通道,清理完主线程后,将克隆主线程中的每个事件对象。

here is a plnkr足够多的单词和难以理解的解释,在其中可能会更清楚地说明其工作原理。

这是一个StackSnippet实时版本,可能很难阅读:

// StackSnippet only: build up internal path to our Worker scripts
const event_delegate_worker_script = document.getElementById( 'event-delegate-worker' ).textContent;
const event_delegate_worker_url = generateScriptURL( event_delegate_worker_script );

const user_script_worker_script = document.getElementById( 'user-script-worker' ).textContent
  .replace( "event-delegate-worker.js", event_delegate_worker_url );
const user_script_worker_url = generateScriptURL( user_script_worker_script );

function generateScriptURL( content ) {
  // Chrome refuses to importScripts blob:// URI...
  return 'data:text/javascript,' + encodeURIComponent( content );
}

// end StackSnippets only

onload = evt => {
  const worker = new EventDelegatingWorker( user_script_worker_url );
  const canvas = document.getElementById( 'canvas' );
  worker.addEventTarget( canvas, "canvas" );

  try {
    const off_canvas = canvas.transferControlToOffscreen();
    worker.postMessage( off_canvas, [ off_canvas ] );    
  }
  catch (e) {
    // no support for OffscreenCanvas, we'll just log evt
    worker.onmessage = (evt) => { console.log( "from worker", evt.data ); }
  }
};
canvas { border: 1px solid; }
<canvas id="canvas"width="500" height="500"></canvas>

<script id="user-script-worker" type="worker-script">
importScripts( "event-delegate-worker.js" );

self.addEventListener( "eventtargetadded", ({ delegatedTarget }) => {
  if( delegatedTarget.context === "canvas" ) {
    delegatedTarget.addEventListener( "mousemove", handleMouseMove );
  }
} );

let ctx;
function handleMouseMove( evt ) {
  if( ctx ) {
    draw( evt.offsetX, evt.offsetY );
  }
  else {
    // so we can log for browsers without OffscreenCanvas
    postMessage( evt );
  }
}

function draw( x, y ) {

  const rad = 30;
  ctx.clearRect( 0, 0, ctx.canvas.width, ctx.canvas.height );
  ctx.beginPath();
  ctx.arc( x, y, rad, 0, Math.PI*2 );
  ctx.fill();

}

onmessage = (evt) => {
  const canvas = evt.data;
  ctx = canvas.getContext("2d");
};
</script>

<!-- below are the two scripts required to bridge the events -->
<script id="event-delegate-main">
(()=> { "use strict";

  const default_event_options_dict = {
    capture: false,
    passive: true
  };
  const event_keys_to_remove = new Set( [
    "view",
    "target",
    "currentTarget"
  ] );
  class EventDelegatingWorker extends Worker {
    constructor( url, options ) {
      super( url, options );
      // this channel will be used to notify the Worker of added targets
      const channel = new MessageChannel();
      this._mainPort = channel.port2;
      this.postMessage( "init-event-delegation", [ channel.port1 ] );
    }
    addEventTarget( event_target, context ) {
      // this channel will be used to notify us when the Worker adds or removes listeners
      // and to notify the worker of new events fired on the target
      const channel = new MessageChannel();
      channel.port1.onmessage = (evt) => {
        const { type, action } = evt.data;
        if( action === "add" ) {
          event_target.addEventListener( type, handleDOMEvent, default_event_options_dict );        
        }
        else if( action === "remove" ) {
          event_target.removeEventListener( type, handleDOMEvent, default_event_options_dict );        
        }
      };
      // let the Worker side know they have a new target they can listen on
      this._mainPort.postMessage( context, [ channel.port2 ] );
      
      function handleDOMEvent( domEvent ) {
        channel.port1.postMessage( sanitizeEvent( domEvent ) );
      }
    }
  }
  window.EventDelegatingWorker = EventDelegatingWorker;

  // Events can not be cloned as is, so we need to stripe out all non cloneable properties
  function sanitizeEvent( evt ) {
    
    const copy = {};
    // Most events only have .isTrusted as own property, so we use a for in loop to get all
    // otherwise JSON.stringify() would just ignore them
    for( let key in evt ) {
      if( event_keys_to_remove.has( key ) ) {
        continue;
      }
      copy[ key ] = evt[ key ];      
    }
    
    const as_string = tryToStringify( copy );
    return JSON.parse( as_string );

    // over complicated recursive function to handle cross-origin access
    function tryToStringify() {
      const referenced_objects = new Set; // for cyclic
      // for cross-origin objects (e.g window.parent in a cross-origin iframe)
      // we save the previous key value so we can delete it if throwing
      let lastKey;  
      let nextVal = copy;
      let lastVal = copy;
      try {
        return JSON.stringify( copy, removeDOMRefsFunctionsAndCyclics );
      }
      catch( e ) {   
        delete lastVal[ lastKey ];
        return tryToStringify();
      }
      
      function removeDOMRefsFunctionsAndCyclics( key, value ) {
        lastVal = nextVal;
        lastKey = key;
        
        if( typeof value === "function" ) {
          return;
        }
        if( typeof value === "string" || typeof value === "number") {
          return value;
        }
        if( value && typeof value === "object" ) {
          if( value instanceof Node ) {
            return;
          }
          if( referenced_objects.has( value ) ) {
            return "[cyclic]";
          }
          referenced_objects.add( value );
          nextVal = value;
          return value;
        }
        return value;
      }
    }
  }

})();
</script>
<script id="event-delegate-worker" type="worker-script">
(()=> { "use strict";

// This script should be imported at the top of user's worker-script
function initDelegatedEventReceiver( evt ) {

  // currently the only option is "once"
  const defaultOptionsDict = {
    once: false,
  };
  // in case it's not our message (which would be quite odd...)
  if( evt.data !== "init-event-delegation" ) {
    return;
  }

  // let's not let user-script know it happend
  evt.stopImmediatePropagation();
  removeEventListener( 'message', initDelegatedEventReceiver, true );

  // this is where the main thread will let us know when a new target is available
  const main_port = evt.ports[ 0 ];

  class EventDelegate {
    constructor( port, context ) {
      this.port = port; // the port to communicate with main
      this.context = context; // can help identify our target
      this.callbacks = {}; // we'll store the added callbacks here
      // this will fire when main thread fired an event on our target
      port.onmessage = (evt) => {
        const evt_object = evt.data;
        const slot = this.callbacks[ evt_object.type ];
        if( slot ) {
          const to_remove = [];
          slot.forEach( ({ callback, options }, index) => {
            try {
              callback( evt_object );
            }
            catch( e ) {
              // we don't want to block our execution,
              // but still, we should notify the exception
              setTimeout( () => { throw e; } );
            }
            if( options.once ) {
              to_remove.push( index );
            }
          } );
          // remove 'once' events
          to_remove.reverse().forEach( index => slot.splice( index, 1 ) );
        }
      };
    }
    addEventListener( type, callback, options = defaultOptionsDict ) {

      const callbacks = this.callbacks;
      let slot = callbacks[ type ];
      if( !slot ) {
        slot = callbacks[ type ] = [];
        // make the main thread attach only a single event,
        // we'll handle the multiple callbacks
        // and since we force { passive: true, capture: false }
        // they'll all get attached the same way there
        this.port.postMessage( { type, action: "add" } );
      }
      // to store internally, and avoid duplicates (like EventTarget.addEventListener does)
      const new_item = {
          callback,
          options,
          options_as_string: stringifyOptions( options )
        };
      if( !getStoredItem( slot, new_item ) ) {
        slot.push( new_item );
      }

    }
    removeEventListener( type, callback, options = defaultOptionsDict ) {

      const callbacks = this.callbacks;
      const slot = callbacks[ type ];
      const options_as_string = stringifyOptions( options );

      const item = getStoredItem( slot, { callback, options, options_as_string } );
      const index = item && slot.indexOf( item );

      if( item ) {
        slot.splice( index, 1 );
      }
      if( slot && !slot.length ) {
        delete callbacks[ type ];
        // we tell the main thread to remove the event handler
        // only when there is no callbacks of this type anymore
        this.port.postMessage( { type, action: "remove" } );
      }

    }
  }
  // EventInitOptions need to be serialized in a deterministic way
  // so we can detect duplicates 
  function stringifyOptions( options ) {
    if( typeof options === "boolean" ) {
      options = { once: options };
    }
    try {
      return JSON.stringify(
        Object.fromEntries(
          Object.entries(
            options
          ).sort( byKeyAlpha )
        )
      );
    } 
    catch( e ) {
      return JSON.stringify( defaultOptionsDict );
    }
  }
  function byKeyAlpha( entry_a, entry_b ) {
    return entry_a[ 0 ].localeCompare( entry_b[ 0 ] );
  }
  
  // retrieves an event item in a slot based on its callback and its stringified options
  function getStoredItem( slot, { callback, options_as_string } ) {
    return Array.isArray( slot ) && slot.find( (obj) => {
      return obj.callback === callback &&
        obj.options_as_string === options_as_string;
    } );
  }

  // a new EventTarget has been declared by main thread
  main_port.onmessage = evt => {
    const target_added_evt = new Event( 'eventtargetadded' );
    target_added_evt.delegatedTarget = new EventDelegate( evt.ports[ 0 ], evt.data );
    dispatchEvent( target_added_evt );
  };
  
}
addEventListener( 'message', initDelegatedEventReceiver );

})();
</script>


Ps:由于已发布此答案,所以我确实开始实现了一个EventPort interface,它是基于this PR的松散形式,它可能更易于使用,并且更接近最​​终规格。
不过要在Stackoverflow答案中发布有点长。
您可以看到live examples here

答案 1 :(得分:0)

有些事情你需要知道:

  1. 每次import {player} from "./playerObject.js";都会创建一个新的播放器实例。因此,它不会将OffscreenCanvasWorker模块与Game模块桥接。
  2. 与其在模块中创建Player类的实例,不如在全局中创建它,然后将其放入Game的构造函数参数和Worker的message参数中。

这是我的意思:

玩家实例创建(全局)

import { Player } from "./Player.js";
const player = new Player();

创建屏幕外画布

const canvas = document.querySelector('#myGame');
const offscreen = canvas.transferControlToOffscreen();
const worker = new Worker('offscreencanvas.js', { type: "module" });
worker.postMessage({
    'canvas' : offscreen,
    'player' : player
}, [offscreen]);

屏幕外画布内部工作者

addEventListener('message', (evt) => {
  const canvas = evt.data.canvas;
  const player = evt.data.player;
  const ctx = canvas.getContext("2d");

  const render = () => {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    player.draw(ctx);

    requestAnimationFrame(render);
  }

  render();
});

内部游戏模块

export class Game {
  constructor(player) {
    this.player = player;
    this.touch();
  }

  touch = () => {
    const player = this.player;
    window.addEventListener('touchstart', (e) => {
      player.updatePos(e.touches[0].clientX, e.touches[0].clientY);

    }, {passive: true});
  }
}

如果您不喜欢这种方式,实际上有很多替代方法,但是最重要的是,您必须传输 玩家实例对象 ,而不是其类或新实例在模块中声明。

其他说明:万一您不知道,在2020年,OffscreenCanvas仍将处于试验阶段,如果将其放到直播网站上将不是一个好主意。祝你好运!