6
\$\begingroup\$

Hey I'm trying to optimise particles.js library from Vincent Garrau in order to get 60fps @ 500 particles ~.

I've already refactored the code to use a quad tree instead but it does not seem to be enough. In fact, I saw no improvement using the quad tree. Maybe I implemented it incorrectly ?

What I'm doing ?

  • Generating n number of particles
  • For each frame : reset the quad tree. For each particle, move it a little and insert it into the tree. Then for each particles, check through a tree query if other particles are close. If there are, draw a line between them. Render the canvas.

Most code below. Full codepen here https://codepen.io/audrenbdb/pen/NWWVpmQ

function update () {
  let particle
  let ms = 0
  quadTree.clear()

  for (let i = 0, l = particlesList.length; i < l; i++) {
    particle = particlesList[i]
    ms = speed / 2
    particle.x += particle.vx * ms
    particle.y += particle.vy * ms

    let new_pos = bounce
      ? {
        x_left: size,
        x_right: canvas.width,
        y_top: size,
        y_bottom: canvas.height
      }
      : {
        x_left: -size,
        x_right: canvas.width + size,
        y_top: -size,
        y_bottom: canvas.height + size
      }

    if (particle.x - size > canvas.width) {
      particle.x = new_pos.x_left
      particle.y = Math.random() * canvas.height
    } else if (particle.x + size < 0) {
      particle.x = new_pos.x_right
      particle.y = Math.random() * canvas.height
    }
    if (particle.y - size > canvas.height) {
      particle.y = new_pos.y_top
      particle.x = Math.random() * canvas.width
    } else if (particle.y + size < 0) {
      particle.y = new_pos.y_bottom
      particle.x = Math.random() * canvas.width
    }

    if (bounce) {
      if (particle.x + size > canvas.width) particle.vx = -particle.vx
      else if (particle.x - size < 0) particle.vx = -particle.vx
      if (particle.y + size > canvas.height) particle.vy = -particle.vy
      else if (particle.y - size < 0) particle.vy = -particle.vy
    }

    if (interaction.status === 'mousemove') {
      repulse(particle)
    }

    draw(particle)

    particle.circle.x = particle.x
    particle.circle.y = particle.y
    particle.circle.r = linkDistance
    quadTree.insert(particle)
  }

  let explored = []
  var i
  var j
  for (i = 0; i < particlesList.length; i++) {
    let links = quadTree.query(particlesList[i].circle)
    for (j = 0; j < links.length; j++) {
      if (links[j] !== particlesList[i] && !explored.includes(links[j])) {
        linkParticles(particlesList[i], links[j])
      }
    }
    explored.push(particlesList[i])
  }
}

function repulse (particle) {
  const dx_mouse = particle.x - interaction.pos_x
  const dy_mouse = particle.y - interaction.pos_y
  const dist_mouse = Math.sqrt(Math.pow(dx_mouse, 2) + Math.pow(dy_mouse, 2))
  const velocity = 100

  const repulseFactor = Math.min(
    Math.max(
      (1 / repulseDistance) *
        (-1 * Math.pow(dist_mouse / repulseDistance, 2) + 1) *
        repulseDistance *
        velocity,
      0
    ),
    50
  )
  let posX = particle.x + (dx_mouse / dist_mouse) * repulseFactor
  let posY = particle.y + (dy_mouse / dist_mouse) * repulseFactor

  if (bounce) {
    if (posX - size > 0 && posX + size < canvas.width) particle.x = posX
    if (posY - size > 0 && posY + size < canvas.height) particle.y = posY
  } else {
    particle.x = posX
    particle.y = posY
  }
}

function createParticle () {
  let x = Math.random() * canvas.width
  let y = Math.random() * canvas.height
  const vx = Math.random() - 0.5
  const vy = Math.random() - 0.5

  if (x > canvas.width - size * 2) x -= size
  else if (x < size * 2) x += size
  if (y > canvas.height - size * 2) y -= size
  else if (y < size * 2) y += size

  let particle = {
    x: x,
    y: y,
    vx: vx,
    vy: vy,
    circle: new Circle(x, y, size)
  }
  return particle
}

function setCanvasSize () {
  canvas.height = canvas.offsetHeight
  canvas.width = canvas.offsetWidth
  boundary = new Rectangle(
    canvas.width / 2,
    canvas.height / 2,
    canvas.width,
    canvas.height
  )
  quadTree = new QuadTree(boundary, 4)

  context = canvas.getContext('2d')
  context.fillRect(0, 0, canvas.width, canvas.height)

  context.fillStyle = `rgba(${particleRGB},1)`;
}

function linkParticles (particle1, particle2) {
  let opacityValue = 1
  const dist = Math.sqrt(
    Math.pow(particle1.x - particle2.x, 2) +
      Math.pow(particle1.y - particle2.y, 2)
  )
  opacityValue = 1 - dist / (.7 * linkDistance)
  context.strokeStyle = `rgba(${linkRGB}, ${opacityValue})`
  context.lineWidth = linkWidth
  context.beginPath()
  context.moveTo(particle1.x, particle1.y)
  context.lineTo(particle2.x, particle2.y)
  context.stroke()
  context.closePath()
}

function draw (particle) {
  context.beginPath()
  context.arc(
    Math.floor(particle.x),
    Math.floor(particle.y),
    size,
    0,
    Math.PI * 2,
    false
  )
  context.closePath()
  context.fill()
}


function animate () {
  context.clearRect(0, 0, canvas.width, canvas.height)
  update()
  requestAnimationFrame(animate)
}

Quad tree code

class Circle {
  constructor (x, y, r) {
    this.x = x
    this.y = y
    this.r = r
  }

  contains (point) {
    let d = Math.pow(point.x - this.x, 2) + Math.pow(point.y - this.y, 2)
    return d <= this.r * this.r
  }

  intersects (range) {
    let xDyst = Math.abs(range.x - this.x)
    let yDist = Math.abs(range.y - this.y)

    let r = this.r

    let w = range.w
    let h = range.h

    let edges = Math.pow(xDist - w, 2) + Math.pow(yDist - h, 2)

    if (xDist > r + w || yDist > r + h) return false
    if (xDist <= w || yDist <= h) return true
    return edges <= this.r * this.r
  }
}

class Rectangle {
  constructor (x, y, w, h) {
    this.x = x
    this.y = y
    this.w = w
    this.h = h
  }

  contains (point) {
    return (
      point.x >= this.x - this.w &&
      point.x <= this.x + this.w &&
      point.y >= this.y - this.h &&
      point.y <= this.y + this.h
    )
  }

  intersects (range) {
    return !(
      range.x - range.w > this.x + this.w ||
      range.x + range.w < this.x - this.w ||
      range.y - range.h > this.y + this.h ||
      range.y + range.h < this.y - this.h
    )
  }
}

class QuadTree {
  constructor (boundary, capacity) {
    this.boundary = boundary
    this.capacity = capacity
    this.points = []
    this.divided = false
  }

  insert (point) {
    if (!this.boundary.contains(point)) return false
    if (this.points.length < this.capacity) {
      this.points.push(point)
      return true
    } else {
      if (!this.divided) {
        this.subdivide()
        this.divided = true
      }

      if (this.northEast.insert(point)) return true
      else if (this.northWest.insert(point)) return true
      else if (this.southEast.insert(point)) return true
      else if (this.southWest.insert(point)) return true
    }
  }

  subdivide () {
    let x = this.boundary.x
    let y = this.boundary.y
    let w = this.boundary.w
    let h = this.boundary.h

    let ne = new Rectangle(x + w / 2, y - h / 2, w / 2, h / 2)
    let nw = new Rectangle(x - w / 2, y - h / 2, w / 2, h / 2)
    let se = new Rectangle(x + w / 2, y + h / 2, w / 2, h / 2)
    let sw = new Rectangle(x - w / 2, y + h / 2, w / 2, h / 2)

    this.northWest = new QuadTree(ne, this.capacity)
    this.northEast = new QuadTree(nw, this.capacity)
    this.southWest = new QuadTree(se, this.capacity)
    this.southEast = new QuadTree(sw, this.capacity)

    this.divided = true
  }

  query (range, found = []) {
    if (!this.boundary.intersects(range)) {
    } else {
      for (let p of this.points) {
        if (range.contains(p)) {
          found.push(p)
        }
      }
      if (this.divided) {
        this.northEast.query(range, found)
        this.northWest.query(range, found)
        this.southEast.query(range, found)
        this.southWest.query(range, found)
      }
      return found
    }
  }

  clear () {
    if (this.divided) {
      delete this.northEast
      delete this.northWest
      delete this.southEast
      delete this.southWest
    }
    this.points = []
    this.divided = false
  }
}
\$\endgroup\$
2
  • \$\begingroup\$ variables are set in the very begining. You can see them in the codepen. \$\endgroup\$ Commented Nov 26, 2019 at 13:14
  • \$\begingroup\$ Ok, I've checked that link. Besides, that visualization is pretty impressive, looks nice \$\endgroup\$ Commented Nov 26, 2019 at 13:18

3 Answers 3

3
\$\begingroup\$

See rewrite example to see how these changes are implemented.

  • Batch render calls to avoid GPU state changes.
  • Reuse arrays, don't delete them or recreate them. In the rewrite the array of found particles is never deleted. Rather than use its length there is a new foundCount that is used to add found points and return the number of points found from QuadTree.query
  • Reuse data structures rather than delete and rebuild. The quadTree uses the close function to close all quads but does not delete the quads so it is much faster next frame as it does not need to rebuild what will be a very similar data structure.
  • Use flags to avoid array searches. The loop where you find links you keep an array of explored items. For each found point you search that array. All you need to do is mark the point as explored saving you having to do all those searches.

There are many more changes but this was a little longer than I expected and I am way over it now.

In the example rewrite I removed the bounce setting (sorry I took it out before I knew what it was for and forgot to put it back.). Your copy has bounce set to false to match the rewrite.

UPDATE

I am refreshed and looked over the code to notice some issues that I have fixed. Poor timing fixed, Quad name miss matches NE was NW, and the distance check in the quad query was too small so doubled the size.

Also added a few more optimizations.

  • Added a depth property to quads. Only starts adding particles to quads 2 levels down or deeper.
  • Quad point arrays do not change length. The quad property pointCount now used rather than the points.length
  • Top three quad layers are never closed.
  • Use the pointCount as early query skip when querying quads.

And some other minor changes unrelated to performance

Your code

Your code copied from the link you provided with a few small mods made to fit the CR snippet and display the render time.

The render time in the top left is the mean render time in ~1second blocks running mean time over 10 rendered frames.

On the laptop I am using this renders in about ~50ms

const number = 200
const speed = 6
const linkWidth = 1
const linkDistance = 120
const size = 2
const repulseDistance = 140
const particleRGB = '255, 255, 255'
const linkRGB = '255, 255, 255'
const bounce = false



let interaction = {
  status: 'mouseleave',
  pos_x: 0,
  pos_y: 0
}
let particlesList = []
let quadTree
let boundary

let canvas
let context

window.onload = () => {
  canvas = document.getElementById('quad-tree')
  canvas.style.height = "100%"
  canvas.style.width = "100%"

  setCanvasSize()
  
  for (let i = 0; i < number; i++) {
    let particle = createParticle()
    particlesList.push(particle)
    quadTree.insert(particle)
  }

  window.addEventListener('resize', () => setCanvasSize())
  canvas.addEventListener('mousemove', e => {
    interaction.pos_x = e.offsetX
    interaction.pos_y = e.offsetY
    interaction.status = 'mousemove'
  })
  canvas.addEventListener('mouseleave', () => {
    interaction.pos_x = null
    interaction.pos_y = null
    interaction.status = 'mouseleave'
  })
  animate()
}

function update () {
  let particle
  let ms = 0
  quadTree.clear()

  for (let i = 0, l = particlesList.length; i < l; i++) {
    particle = particlesList[i]
    ms = speed / 2
    particle.x += particle.vx * ms
    particle.y += particle.vy * ms

    let new_pos = bounce
      ? {
        x_left: size,
        x_right: canvas.width,
        y_top: size,
        y_bottom: canvas.height
      }
      : {
        x_left: -size,
        x_right: canvas.width + size,
        y_top: -size,
        y_bottom: canvas.height + size
      }

    if (particle.x - size > canvas.width) {
      particle.x = new_pos.x_left
      particle.y = Math.random() * canvas.height
    } else if (particle.x + size < 0) {
      particle.x = new_pos.x_right
      particle.y = Math.random() * canvas.height
    }
    if (particle.y - size > canvas.height) {
      particle.y = new_pos.y_top
      particle.x = Math.random() * canvas.width
    } else if (particle.y + size < 0) {
      particle.y = new_pos.y_bottom
      particle.x = Math.random() * canvas.width
    }

    if (bounce) {
      if (particle.x + size > canvas.width) particle.vx = -particle.vx
      else if (particle.x - size < 0) particle.vx = -particle.vx
      if (particle.y + size > canvas.height) particle.vy = -particle.vy
      else if (particle.y - size < 0) particle.vy = -particle.vy
    }

    if (interaction.status === 'mousemove') {
      repulse(particle)
    }

    draw(particle)

    particle.circle.x = particle.x
    particle.circle.y = particle.y
    particle.circle.r = linkDistance
    quadTree.insert(particle)
  }

  let explored = []
  var i
  var j
  for (i = 0; i < particlesList.length; i++) {
    let links = quadTree.query(particlesList[i].circle)
    for (j = 0; j < links.length; j++) {
      if (links[j] !== particlesList[i] && !explored.includes(links[j])) {
        linkParticles(particlesList[i], links[j])
      }
    }
    explored.push(particlesList[i])
  }
}

function repulse (particle) {
  const dx_mouse = particle.x - interaction.pos_x
  const dy_mouse = particle.y - interaction.pos_y
  const dist_mouse = Math.sqrt(Math.pow(dx_mouse, 2) + Math.pow(dy_mouse, 2))
  const velocity = 100

  const repulseFactor = Math.min(
    Math.max(
      (1 / repulseDistance) *
        (-1 * Math.pow(dist_mouse / repulseDistance, 2) + 1) *
        repulseDistance *
        velocity,
      0
    ),
    50
  )
  let posX = particle.x + (dx_mouse / dist_mouse) * repulseFactor
  let posY = particle.y + (dy_mouse / dist_mouse) * repulseFactor

  if (bounce) {
    if (posX - size > 0 && posX + size < canvas.width) particle.x = posX
    if (posY - size > 0 && posY + size < canvas.height) particle.y = posY
  } else {
    particle.x = posX
    particle.y = posY
  }
}

function createParticle () {
  let x = Math.random() * canvas.width
  let y = Math.random() * canvas.height
  const vx = Math.random() - 0.5
  const vy = Math.random() - 0.5

  if (x > canvas.width - size * 2) x -= size
  else if (x < size * 2) x += size
  if (y > canvas.height - size * 2) y -= size
  else if (y < size * 2) y += size

  let particle = {
    x: x,
    y: y,
    vx: vx,
    vy: vy,
    circle: new Circle(x, y, size)
  }
  return particle
}

function setCanvasSize () {
  canvas.height = innerHeight
  canvas.width = innerWidth
  boundary = new Rectangle(
    canvas.width / 2,
    canvas.height / 2,
    canvas.width,
    canvas.height
  )
  quadTree = new QuadTree(boundary, 4)

  context = canvas.getContext('2d')
  context.fillRect(0, 0, canvas.width, canvas.height)

  context.fillStyle = `rgba(${particleRGB},1)`;
}

function linkParticles (particle1, particle2) {
  let opacityValue = 1
  const dist = Math.sqrt(
    Math.pow(particle1.x - particle2.x, 2) +
      Math.pow(particle1.y - particle2.y, 2)
  )
  opacityValue = 1 - dist / 80
  context.strokeStyle = `rgba(${linkRGB}, ${opacityValue})`
  context.lineWidth = linkWidth
  context.beginPath()
  context.moveTo(particle1.x, particle1.y)
  context.lineTo(particle2.x, particle2.y)
  context.stroke()
  context.closePath()
}

function draw (particle) {
  context.beginPath()
  context.arc(
    Math.floor(particle.x),
    Math.floor(particle.y),
    size,
    0,
    Math.PI * 2,
    false
  )
  context.closePath()
  context.fill()
}


var times = [];
var renderCount = 0 
function animate () {
  context.clearRect(0, 0, canvas.width, canvas.height)
  const now = performance.now();
  update()
    renderCount += 1;
    times[renderCount % 10] = (performance.now() - now);
    const total = times.reduce((total, time) => total + time, 0);

    info.textContent = "Running ave render time: " + (total / times.length).toFixed(3) + "ms";
  requestAnimationFrame(animate)
}


class Circle {
  constructor (x, y, r) {
    this.x = x
    this.y = y
    this.r = r
  }

  contains (point) {
    let d = Math.pow(point.x - this.x, 2) + Math.pow(point.y - this.y, 2)
    return d <= this.r * this.r
  }

  intersects (range) {
    let xDyst = Math.abs(range.x - this.x)
    let yDist = Math.abs(range.y - this.y)

    let r = this.r

    let w = range.w
    let h = range.h

    let edges = Math.pow(xDist - w, 2) + Math.pow(yDist - h, 2)

    if (xDist > r + w || yDist > r + h) return false
    if (xDist <= w || yDist <= h) return true
    return edges <= this.r * this.r
  }
}

class Rectangle {
  constructor (x, y, w, h) {
    this.x = x
    this.y = y
    this.w = w
    this.h = h
  }

  contains (point) {
    return (
      point.x >= this.x - this.w &&
      point.x <= this.x + this.w &&
      point.y >= this.y - this.h &&
      point.y <= this.y + this.h
    )
  }

  intersects (range) {
    return !(
      range.x - range.w > this.x + this.w ||
      range.x + range.w < this.x - this.w ||
      range.y - range.h > this.y + this.h ||
      range.y + range.h < this.y - this.h
    )
  }
}

class QuadTree {
  constructor (boundary, capacity) {
    this.boundary = boundary
    this.capacity = capacity
    this.points = []
    this.divided = false
  }

  insert (point) {
    if (!this.boundary.contains(point)) return false
    if (this.points.length < this.capacity) {
      this.points.push(point)
      return true
    } else {
      if (!this.divided) {
        this.subdivide()
        this.divided = true
      }

      if (this.northEast.insert(point)) return true
      else if (this.northWest.insert(point)) return true
      else if (this.southEast.insert(point)) return true
      else if (this.southWest.insert(point)) return true
    }
  }

  subdivide () {
    let x = this.boundary.x
    let y = this.boundary.y
    let w = this.boundary.w
    let h = this.boundary.h

    let ne = new Rectangle(x + w / 2, y - h / 2, w / 2, h / 2)
    let nw = new Rectangle(x - w / 2, y - h / 2, w / 2, h / 2)
    let se = new Rectangle(x + w / 2, y + h / 2, w / 2, h / 2)
    let sw = new Rectangle(x - w / 2, y + h / 2, w / 2, h / 2)

    this.northWest = new QuadTree(ne, this.capacity)
    this.northEast = new QuadTree(nw, this.capacity)
    this.southWest = new QuadTree(se, this.capacity)
    this.southEast = new QuadTree(sw, this.capacity)

    this.divided = true
  }

  query (range, found = []) {
    if (!this.boundary.intersects(range)) {
    } else {
      for (let p of this.points) {
        if (range.contains(p)) {
          found.push(p)
        }
      }
      if (this.divided) {
        this.northEast.query(range, found)
        this.northWest.query(range, found)
        this.southEast.query(range, found)
        this.southWest.query(range, found)
      }
      return found
    }
  }

  clear () {
    if (this.divided) {
      delete this.northEast
      delete this.northWest
      delete this.southEast
      delete this.southWest
    }
    this.points = []
    this.divided = false
  }
}
.float {
  position: absolute;
  top: 0px;
  left: 0px;
}
canvas {
  background: #59F;
}
  
<canvas class="float" id="quad-tree"></canvas>
<code class="float" id="info"></code>

Rewrite 20 times faster.

Again the time is in the top corner to show performance.

"use strict";
const number = 200;
const speed = 1;
const linkWidth = 0.5;
const linkDistance = 120;
const size = 1.5;
var repulseDistance = repulseDistance = Math.min(innerWidth,innerHeight) / 6;
const PARTICLES_PER_QUAD = 4;
const linkDistance2 = (0.7 * linkDistance) ** 2;
const repulseDistance2 = repulseDistance ** 2;
var showQuads = false;
Math.TAU = Math.PI * 2;
const particleStyle = "#FFF";
const linkRGB = "#FFF";
const quadStyle = "#F00";
const candidates = [];
var W,H;

const mouse = { x: 0, y: 0}
const particlesList = [];
const links = [[],[],[],[]];
const linkBatchAlphas = [0.2, 0.4, 0.7, 0.9];
const linkBatches = links.length;
const linkPool = [];
let quadTree;
let boundary;
const ctx = canvas.getContext("2d");
W = canvas.height = innerHeight;
H = canvas.width = innerWidth;
canvas.addEventListener('mousemove', e => {
    mouse.x = e.offsetX;
    mouse.y = e.offsetY;
})
canvas.addEventListener('click', e => {
    showQuads = !showQuads;
    
})

setTimeout(start, 42);

function start(){ 
    quadTree = new QuadTree();
    for (let i = 0; i < number; i++) {
      particlesList.push(new Particle(canvas, size));
    }

    animate();
}

 
var times = [];
var renderCount = 0 
function animate () {
    if (canvas.width !== innerWidth || canvas.height !== innerHeight) { setCanvasSize() }
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    const now = performance.now();
    updateParticles();
    updateLinks();


    renderCount += 1;
    times[renderCount % 10] = (performance.now() - now);
    const total = times.reduce((total, time) => total + time, 0);

    info.textContent = "Running ave render time: " + (total / times.length).toFixed(3) + "ms";


    requestAnimationFrame(animate);

}

function updateParticles() {	
    quadTree.close();
    ctx.fillStyle = particleStyle;
    ctx.beginPath();
    for (const particle of particlesList) { particle.update(ctx, true) }
    ctx.fill();
}
function updateLinks() {

    var i,j, link;
    
    if(showQuads) {
        ctx.strokeStyle = quadStyle;
        ctx.lineWidth = 1;
        ctx.beginPath();
    }
    for(const p1 of particlesList) {
        p1.explored = true;
        const count = quadTree.query(p1, 0, candidates);
        for (j = 0; j < count; j++) {
            const p2 = candidates[j];
            if (!p2.explored) {
                link = linkPool.length ? linkPool.pop() : new Link();
                link.init(p1, candidates[j]);
                links[link.batchId].push(link);
            }
        }
    }
    if(showQuads) { ctx.stroke() }
    var alphaIdx = 0;
    ctx.lineWidth = linkWidth;
    ctx.strokeStyle = linkRGB;
    for(const l of links) {
        ctx.globalAlpha = linkBatchAlphas[alphaIdx++]; 
        
        ctx.beginPath();
        while(l.length) { linkPool.push(l.pop().addPath(ctx)) }
        ctx.stroke();
    }
    ctx.globalAlpha = 1;
}
function resetParticles() {
    quadTree = new QuadTree();
    for (const particle of particlesList) { particle.reset(canvas) };

   
}

function setCanvasSize () {
    W = canvas.height = innerHeight;
    H = canvas.width = innerWidth;
    repulseDistance = Math.min(W,H) / 12;
    resetParticles();
}



class Link {
    constructor() {  }
    init(p1, p2) { // p1,p2 are particles
        this.p1 = p1;
        this.p2 = p2;
        const dx = p1.x - p2.x;
        const dy = p1.y - p2.y;
        this.alpha = 1 - (dx * dx + dy * dy) / linkDistance2;
        this.batchId = this.alpha * linkBatches | 0;
        this.batchId = this.batchId >= linkBatches ? linkBatches : this.batchId;
    }		
    addPath(ctx) {
        ctx.moveTo(this.p1.x, this.p1.y);
        ctx.lineTo(this.p2.x, this.p2.y);
        return this;
    }
	
}


class Particle {
    constructor (canvas, r) {
        this.r = r;
        this.speedScale = speed / 2;
        this.reset(canvas, r);
    }
    reset(canvas, r = this.r) {
        const W = canvas.width - r * 2;  // Canvas width and height reduced so 
                                         // that the bounds check is not needed
        const H = canvas.height - r * 2;
        this.x = Math.random() * W + r;
        this.y = Math.random() * H + r;
        this.vx = Math.random() - 0.5;
        this.vy = Math.random() - 0.5;
        this.quad = undefined;
        this.explored = false;

    }
	  addPath(ctx) {
	      //ctx.moveTo(this.x + this.r,  this.y);
          //ctx.arc(this.x,  this.y, this.r, 0, Math.TAU);
          ctx.rect(this.x - this.r,  this.y - this.r, this.r * 2,  this.r * 2);
	  }
	  near(p) {
		    return ((p.x - this.x) ** 2 + (p.y - this.y) ** 2) <= linkDistance2;
	  }
    intersects(range) {
        const xd = Math.abs(range.x - this.x);
        const yd = Math.abs(range.y - this.y);
        const r = linkDistance;
        const w = range.w;
        const h = range.h;
        if (xd > r + w || yd > r + h) { return false }
        if (xd <= w || yd <= h) { return true }
        return  ((xd - w) ** 2 + (yd - h) ** 2) <= linkDistance2;

    }
    update(ctx, repulse = true) { 
        this.explored = false;
        const r = this.r;
        const W = ctx.canvas.width + r;
        const H = ctx.canvas.height + r;
        this.x += this.vx * this.speedScale;
        this.y += this.vy * this.speedScale;
        if (this.x > W) {
            this.x = 0;
            this.y = Math.random() * (H - r);
        } else if (this.x < -r) {
            this.x = W - r;
            this.y = Math.random() * (H - r);
        }
        if (this.y > H) {
            this.y = 0
            this.x = Math.random() * (W - r);
        } else if (this.y < -r) {
            this.y = H - r;
            this.x = Math.random() * (W - r);
        }
        repulse && this.repulse();
        this.addPath(ctx);
        quadTree.insert(this);
        this.quad && (this.quad.drawn = false)
    }
    repulse() {
        // I have simplified the math (I did not check as I went so behaviour may vary a little from the original)
        var dx = this.x - mouse.x;
        var dy = this.y - mouse.y;

        const dist = (dx * dx + dy * dy) ** 0.5;
        var rf = ((1 - (dist / repulseDistance) ** 2)  * 100);
            rf = (rf < 0 ? 0 : rf > 50  ? 50 : rf) / dist; // ternary is quicker than Math.max(Math.min(rf,50), 0) 
        this.x += dx * rf;
        this.y += dy * rf;
    }
}

class Bounds {
    constructor(x, y, w, h) { this.init(x, y, w, h) }
    init(x,y,w,h) { 
        this.x = x; 
        this.y = y; 
        this.w = w; 
        this.h = h; 
        this.left = x - w;
        this.right = x + w;
        this.top = y - h;
        this.bottom = y + h;
        this.diagonal = (w * w + h * h);
    }

    contains(p) {
        return (p.x >= this.left && p.x <= this.right && p.y >= this.top && p.y <= this.bottom);
    }

    near(p) {
        if (!this.contains(p)) {
            const dx = p.x - this.x;
            const dy = p.y - this.y;
            const dist = (dx * dx + dy * dy) - this.diagonal - linkDistance2;
            return dist < 0;
        }
        return true;
    }
}

class QuadTree {
    constructor(boundary, depth = 0) {
		this.boundary = boundary || new Bounds(canvas.width / 2,canvas.height / 2,canvas.width / 2 ,canvas.height / 2);
        this.divided = false;		
        this.points = depth > 1 ? [] : null;
        this.pointCount = 0
        this.drawn = false;
        this.depth = depth;
        if(depth === 0) {   // BM67 Fix on resize
            this.subdivide();
            this.NE.subdivide();
            this.NW.subdivide();
            this.SE.subdivide();
            this.SW.subdivide();
        }


    }

    addPath() {  // getting ctx from global as this was a last min change
        const b = this.boundary;
        ctx.rect(b.left, b.top, b.w * 2, b.h * 2);
        this.drawn = true;
    }
    addToSubQuad(particle) {
        if (this.NE.insert(particle)) { return true }
        if (this.NW.insert(particle)) { return true }
        if (this.SE.insert(particle)) { return true }
        if (this.SW.insert(particle)) { return true }	
        particle.quad = undefined;		
    }
    insert(particle) {
        if (this.depth > 0 && !this.boundary.contains(particle)) { return false }
        
        if (this.depth > 1 && this.pointCount < PARTICLES_PER_QUAD) { 
            this.points[this.pointCount++] = particle;
            particle.quad = this;
            return true;
        } 
        if (!this.divided) { this.subdivide() }
        return this.addToSubQuad(particle);
    }

    subdivide() {
        if (!this.NW) {  // if this is undefined we know all 4 are undefined
            const x = this.boundary.x;
            const y = this.boundary.y;
            const w = this.boundary.w / 2;
            const h = this.boundary.h / 2;
            const depth = this.depth + 1;

            this.NE = new QuadTree(new Bounds(x + w, y - h, w, h), depth);
            this.NW = new QuadTree(new Bounds(x - w, y - h, w, h), depth); 
            this.SE = new QuadTree(new Bounds(x + w, y + h, w, h), depth);
            this.SW = new QuadTree(new Bounds(x - w, y + h, w, h), depth);
        } else {
            this.NE.pointCount = 0;
            this.NW.pointCount = 0;            
            this.SE.pointCount = 0;
            this.SW.pointCount = 0;            
        }

        this.divided = true;
    }
    query(part, fc, found) { //part = particle  fc found count
        var i = this.pointCount;
        if (this.depth === 0 || this.boundary.near(part)) {
            if (this.depth > 1) {
                showQuads && !this.drawn && this.addPath();
                while (i--) {
                    const p = this.points[i];
                    if (!p.explored && part.near(p)) { found[fc++] = p }
                }
                if (this.divided) {
                    fc = this.NE.pointCount ? this.NE.query(part, fc, found) : fc;
                    fc = this.NW.pointCount ? this.NW.query(part, fc, found) : fc;
                    fc = this.SE.pointCount ? this.SE.query(part, fc, found) : fc;
                    fc = this.SW.pointCount ? this.SW.query(part, fc, found) : fc;
                }
            } else if(this.divided) { // BM67 Fix on resize
                fc = this.NE.query(part, fc, found);
                fc = this.NW.query(part, fc, found);
                fc = this.SE.query(part, fc, found);
                fc = this.SW.query(part, fc, found);
            }
        }
        return fc;
    }

    close() {
        if (this.divided) {
           this.NE.close();
           this.NW.close();
           this.SE.close();
           this.SW.close();
        }
      
        if (this.depth === 2 && this.divided) { // BM67 Fix on resize
            this.NE.pointCount = 0;
            this.NW.pointCount = 0;
            this.SE.pointCount = 0;
            this.SW.pointCount = 0;
        } else if (this.depth > 2) {
            this.divided = false;
        }
    }
}
.float {
  position: absolute;
  top: 0px;
  left: 0px;
}
canvas {
  background: #59F;
}
<canvas class="float" id="canvas"></canvas>
<code class="float" id="info"></code>

\$\endgroup\$
4
  • \$\begingroup\$ That's amazing, thanks, on my way to read, understand then refactor ! \$\endgroup\$ Commented Nov 27, 2019 at 6:34
  • \$\begingroup\$ Yes I've seen the switch between NW and NE, thought you were tired ! I'm adding the repulse function. Also I've noticed that I can use drawImage() and "duplicate" the same particle in many different positions. I've seen a very significant perf gain. I'll try to post asap. You nailed alot of optimizations! \$\endgroup\$ Commented Nov 27, 2019 at 11:29
  • \$\begingroup\$ I get quite often pointCount is undefined when resizing multiple times the window. I have a hard time grasping out what happens. \$\endgroup\$ Commented Nov 27, 2019 at 14:23
  • \$\begingroup\$ @AdoRen Could not reproduce error but there are some bad points in the code when resizing creates a new quad tree and code assumes its already built. Have added update to snippet and marked them with // BM67 Fix on resize In QuadTree constructor, QuadTree.close and QuadTree.query with luck that will solve the problem. \$\endgroup\$ Commented Nov 27, 2019 at 14:50
2
\$\begingroup\$

Some set of functional improvements/corrections for QuadTree class

  • QuadTree.insert method.
    In such a conditional:

    if (this.points.length < this.capacity) {
        this.points.push(point)
        return true
    } else {
        ...
    

    specifying else branch doesn't make sense as the 1st if branch, if truthy, will return immediately from the function.

    A sequence of exclusive conditions

    if (this.northEast.insert(point)) return true
    else if (this.northWest.insert(point)) return true
    else if (this.southEast.insert(point)) return true
    else if (this.southWest.insert(point)) return true
    

    returns the same result, therefore can be appropriately consolidated.

    The final optimized version:

    insert(point) {
        if (!this.boundary.contains(point)) return false
        if (this.points.length < this.capacity) {
            this.points.push(point)
            return true
        }
        if (!this.divided) {
            this.subdivide()
            this.divided = true
        }
    
        if ((this.northEast.insert(point)) ||
            (this.northWest.insert(point)) ||
            (this.southEast.insert(point)) ||
            (this.southWest.insert(point))) return true
    }
    
  • QuadTree.subdivide method.
    As boundary width w and height h are only used in context of their halved values - those factors can be calculated at once to avoid 16 repetitive divisions:

    subdivide() {
        let x = this.boundary.x,
            y = this.boundary.y,
            w = this.boundary.w / 2,
            h = this.boundary.h / 2;
    
        let ne = new Rectangle(x + w, y - h, w, h);
        let nw = new Rectangle(x - w, y - h, w, h);
        let se = new Rectangle(x + w, y + h, w, h);
        let sw = new Rectangle(x - w, y + h, w, h);
    
        this.northWest = new QuadTree(ne, this.capacity);
        this.northEast = new QuadTree(nw, this.capacity);
        this.southWest = new QuadTree(se, this.capacity);
        this.southEast = new QuadTree(sw, this.capacity);
    
        this.divided = true;
    }
    
  • QuadTree.query method.
    The construction

    if (!this.boundary.intersects(range)) {
    } else {
    

    is just an awkwardly flipped equivalent to:

    if (this.boundary.intersects(range)) {
        ...
    

    Iterating over this.points with

    for (let p of this.points) {
        if (range.contains(p)) {
            found.push(p)
        }
    }
    

    can be flexibly replaced with Array.filter approach.
    Eventually the function/method would look as:

    query(range, found = []) {
        if (this.boundary.intersects(range)) {
            found.push(...this.points.filter((p) => range.contains(p)));
            if (this.divided) {
                this.northEast.query(range, found)
                this.northWest.query(range, found)
                this.southEast.query(range, found)
                this.southWest.query(range, found)
            }
            return found
        }
    }
    
\$\endgroup\$
1
  • \$\begingroup\$ thanks alot I'll look into it. I think I also need to nail ways to improve how I render canvas and its particles \$\endgroup\$ Commented Nov 26, 2019 at 13:20
2
\$\begingroup\$

Great question, some possible pointers;

  • Use the Chrome Dev Tool Performance, it will show you where the code slows down: enter image description here
  • Avoid floating-point coordinates and use integers instead with canvas calls, so

     context.moveTo(~~particle1.x, ~~particle1.y)
     context.lineTo(~~particle2.x, ~~particle2.y)
    
  • A double bitwise NOT ~~ is the same as calling, Math.floor() but faster.

    It made linkParticles 30% faster for me

  • Because this does not require a transparent background, you could instantiate the context like this

    var ctx = canvas.getContext('2d', { alpha: false })

    and then replace clearRect with fillRect so that you get your blue background

  • Finally, you want to consider batching up your lines, because ultimately the program draws very large spiderweb like images.
  • I was reading up on OffscreenCanvas, but I don't think it will speed this up much
  • Finally, it seems that the basic Canvas methods are slowing this down, you may want to investigate rewriting this with WebGL

You can find some good reading here.

\$\endgroup\$
5
  • \$\begingroup\$ Thanks for these nice suggestions. What do you mean by batching up your lines ? \$\endgroup\$ Commented Nov 26, 2019 at 20:30
  • \$\begingroup\$ I mean to draw more than 1 line with 1 openpath/ closepath pair, a lot of lines are connected you could draw them in 1 go. \$\endgroup\$ Commented Nov 26, 2019 at 21:46
  • \$\begingroup\$ excellent idea, I'll post an update soon \$\endgroup\$ Commented Nov 26, 2019 at 22:09
  • \$\begingroup\$ I don't think I can draw more than 1 line per open path considering opacity is different on each line. \$\endgroup\$ Commented Nov 26, 2019 at 22:48
  • \$\begingroup\$ Found a good improvement though. Create a single particle canvas that I just use on every draw()call with drawImage \$\endgroup\$ Commented Nov 26, 2019 at 23:04

You must log in to answer this question.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.