Skip to content

Significant FPS drop with simple Container addMask() usage in WebGL #7306

@Michael--

Description

@Michael--

Version

  • Phaser Version: 4.1.0
  • Operating system: MacOS Apple M4
  • Browser: Chrome Version 148.0.7778.169 (arm64), Safari Version 26.5 (21624.2.5.11.4)

Description

Using a very simple WebGL scene with a 4x4 grid of Containers, each Container has enableFilters() and exactly one filters.internal.addMask(...) call.

Even though the visual setup is simple (basic rectangles/circles, no heavy effects), FPS drops significantly when the masked containers are active.

Expected behavior:

  • This simple mask usage should have low overhead and stable FPS on desktop GPUs.

Actual behavior:

  • Noticeable FPS drop when masks are created.
  • FPS recovers when the same objects are removed.

Repro flow:

  1. Start scene (masked containers created).
  2. Observe FPS drop.
  3. Stop/remove objects.
  4. Observe FPS recovery.

Example Test Code

import * as Phaser from 'phaser'

const COLS = 4
const ROWS = 4
const root = document.getElementById('app')!
Object.assign(document.body.style, { margin: '0', overflow: 'hidden' })
Object.assign(root.style, { width: '100vw', height: '100vh' })

type Cell = { container: Phaser.GameObjects.Container; extras: Phaser.GameObjects.GameObject[] }

class ReproScene extends Phaser.Scene {
  private info?: Phaser.GameObjects.Text
  private startBtn?: Phaser.GameObjects.Text
  private stopBtn?: Phaser.GameObjects.Text
  private cells: Cell[] = []
  private running = false

  create(): void {
    this.info = this.add.text(12, 12, '', {
      fontFamily: 'monospace',
      fontSize: '14px',
      color: '#e2e8f0',
      backgroundColor: '#111827cc',
      padding: { x: 8, y: 6 },
    }).setDepth(1000)

    this.startBtn = this.makeButton('START', '#14532d', () => this.start()).setPosition(20, 120)
    this.stopBtn = this.makeButton('STOP', '#7f1d1d', () => this.stop()).setPosition(110, 120)

    this.start()
  }

  override update(): void {
    this.info?.setText([
      'Phaser 4 Mask Repro',
      `renderer: ${this.game.renderer.type === Phaser.WEBGL ? 'WebGL' : 'Canvas'}`,
      `running: ${this.running ? 'yes' : 'no'}`,
      `containers: ${this.cells.length}`,
      `fps: ${Math.round(this.game.loop.actualFps)}`,
    ])
  }

  private makeButton(label: string, bg: string, onClick: () => void): Phaser.GameObjects.Text {
    return this.add.text(0, 0, label, {
      fontFamily: 'monospace',
      fontSize: '13px',
      color: '#fff',
      backgroundColor: bg,
      padding: { x: 9, y: 6 },
    }).setInteractive({ useHandCursor: true }).on('pointerdown', onClick) as Phaser.GameObjects.Text
  }

  private start(): void {
    if (this.running) return
    const w = this.scale.width
    const h = this.scale.height
    const padX = 20
    const top = 170
    const gap = 14
    const cellW = (w - padX * 2 - gap * (COLS - 1)) / COLS
    const cellH = (h - top - 20 - gap * (ROWS - 1)) / ROWS

    for (let r = 0; r < ROWS; r++) {
      for (let c = 0; c < COLS; c++) {
        const cx = padX + c * (cellW + gap) + cellW * 0.5
        const cy = top + r * (cellH + gap) + cellH * 0.5

        const box = this.add.rectangle(0, 0, cellW * 0.86, cellH * 0.86, 0x3a86ff, 0.9).setStrokeStyle(2, 0xffffff, 0.7)
        const container = this.add.container(cx, cy, [box])

        // visible helper shapes (not used as mask source)
        const maskCircle = this.add.circle(cx, cy, Math.min(cellW, cellH) * 0.26, 0xffffff, 0.24)
        const maskBar = this.add.rectangle(cx, cy, cellW * 0.36, cellH * 0.9, 0xffffff, 0.24).setRotation(0.4)

        // actual mask source
        const maskHole = this.add.circle(cx, cy, Math.min(cellW, cellH) * 0.1, 0xffffff, 0.24)

        container.enableFilters()
        container.filters?.internal.addMask(maskHole, true, this.cameras.main, 'world')

        this.cells.push({ container, extras: [maskCircle, maskBar, maskHole] })
      }
    }

    this.running = true
  }

  private stop(): void {
    for (const cell of this.cells) {
      cell.container.destroy(true)
      for (const obj of cell.extras) obj.destroy()
    }
    this.cells = []
    this.running = false
  }
}

new Phaser.Game({
  type: Phaser.WEBGL,
  parent: root,
  width: '100%',
  height: '100%',
  scene: [ReproScene],
  scale: { mode: Phaser.Scale.RESIZE, autoCenter: Phaser.Scale.CENTER_BOTH },
})

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions