typescriptvue.jscanvasboids

Porting Vue component to HTML Canvas


I've been developing a boids simulation, commonly referred to as "flocking". Initially, I rendered the boids on the screen using a Vue component without a canvas. However, I've recently encountered issues with the boids' behavior.

Consequently, I decided to transition the boids to a canvas, aiming to improve debug information visualization. In this process, I endeavored to keep the boids' data separate from the frontend to facilitate this migration. Although the code remains largely unchanged between versions, the canvas is exhibiting an unusual simulation behavior.

Specific issues include the boids being scaled improperly and appearing in low quality. Additionally, the boids tend to go out of bounds. I've examined the x and y coordinates of the boids to determine if they are displayed differently on the canvas, but the root of the problem remains elusive.

If any further details are required, please let me know, and I will update my question accordingly.

Here is an envoirement to see my code: link I will also display some code below. For all the code please click the link provided before.

Boid.ts

This is where all the logic happens. It is responsible for all the boids rules eg. cohesion, seperation and alignment.

import {Vector} from "assets/js/types/Vector";

export class Boid {
    public static BOID_SIZE: number = 25;
    public desiredSeparation: number = 25;
    public desiredCohesion: number = 0.05;
    public desiredAlignment: number = 50;

    private wanderAngle: number = 0;
    position: Vector;
    velocity: Vector;
    acceleration: Vector;
    maxForce: number = 0.1;
    maxSpeed: number = 2;
    rotation: number = 0;

    constructor() {
        this.position = new Vector(Math.random() * 500, Math.random() * 500);
        this.velocity = new Vector(Math.random() * 2 - 1, Math.random() * 2 - 1);
        this.acceleration = new Vector(0, 0);
    }

    update(bounds: { width: number; height: number; }, boids: Boid[]) {
        this.applyBehaviors(boids);
        this.updateMotion();
        this.checkBounds(bounds.width, bounds.height);
    }

    updateMotion() {
        this.rotation = this.velocity.angle();
        this.velocity = this.velocity.add(this.acceleration);
        this.velocity = this.velocity.limit(this.maxSpeed);
        this.position = this.position.add(this.velocity);
        this.acceleration = this.acceleration.multiply(0);
    }

    draw(ctx: CanvasRenderingContext2D) {
        ctx.save();
        ctx.translate(this.position.x, this.position.y);
        ctx.rotate(this.velocity.angle());
        ctx.beginPath();
        ctx.moveTo(0, -Boid.BOID_SIZE);
        ctx.lineTo(-Boid.BOID_SIZE, Boid.BOID_SIZE);
        ctx.lineTo(Boid.BOID_SIZE, Boid.BOID_SIZE);
        ctx.closePath();
        ctx.fillStyle = '#558cf4';
        ctx.fill();
        ctx.restore();
    }

    applyForce(force: Vector) {
        this.acceleration = this.acceleration.add(force);
    }

    applyBehaviors(boids: Boid[]) {
        const separateForce = this.separation(boids);
        const alignForce = this.alignment(boids);
        const cohesionForce = this.cohesion(boids);

        separateForce.multiply(1.5);
        alignForce.multiply(1.0);
        cohesionForce.multiply(1.0);

        this.applyForce(separateForce);
        this.applyForce(alignForce);
        this.applyForce(cohesionForce);

        const wanderForce = this.wander();
        this.applyForce(wanderForce);
    }

    seek(target: Vector) {
        let desired = target.subtract(this.position);
        desired = desired.normalize();
        desired = desired.multiply(this.maxSpeed);
        let steer = desired.subtract(this.velocity);
        steer = steer.limit(this.maxForce);
        return steer;
    }

    wander() {
        let wanderR = 25;
        let wanderD = 80;
        let change = 0.3;
        this.wanderAngle += Math.random() * change - change * .5;
        let circlePos = this.velocity;
        circlePos = circlePos.normalize();
        circlePos = circlePos.multiply(wanderD);
        circlePos = circlePos.add(this.position);
        let h = this.velocity.angle();
        let circleOffset = new Vector(wanderR * Math.cos(this.wanderAngle + h), wanderR * Math.sin(this.wanderAngle + h));
        let target = circlePos.add(circleOffset);
        return this.seek(target);
    }

    cohesion(boids: Boid[]) {
        let sum = new Vector(0, 0);
        let count = 0;
        for (let i = 0; i < boids.length; i++) {
            let d = this.position.distance(boids[i].position);
            if ((d > 0) && (d < this.desiredCohesion)) {
                sum = sum.add(boids[i].position);
                count++;
            }
        }
        if (count > 0) {
            sum = sum.divide(count);
            return this.seek(sum);
        } else {
            return new Vector(0, 0);
        }
    }

    separation(boids: Boid[]) {
        let steer = new Vector(0, 0);
        let count = 0;
        for (let i = 0; i < boids.length; i++) {
            let d = this.position.distance(boids[i].position);
            if ((d > 0) && (d < this.desiredSeparation)) {
                let diff = this.position.subtract(boids[i].position);
                diff = diff.normalize();
                diff = diff.divide(d);
                steer = steer.add(diff);
                count++;
            }
        }
        if (count > 0) {
            steer = steer.divide(count);
        }
        if (steer.magnitude() > 0) {
            steer = steer.normalize();
            steer = steer.multiply(this.maxSpeed);
            steer = steer.subtract(this.velocity);
            steer = steer.limit(this.maxForce);
        }
        return steer;
    }

    alignment(boids: Boid[]) {
        let sum = new Vector(0, 0);
        let count = 0;
        for (let i = 0; i < boids.length; i++) {
            let d = this.position.distance(boids[i].position);
            if ((d > 0) && (d < this.desiredAlignment)) {
                sum = sum.add(boids[i].velocity);
                count++;
            }
        }
        if (count > 0) {
            sum = sum.divide(count);
            sum = sum.normalize();
            sum = sum.multiply(this.maxSpeed);
            let steer = sum.subtract(this.velocity);
            steer = steer.limit(this.maxForce);
            return steer;
        } else {
            return new Vector(0, 0);
        }
    }

    checkBounds(width: number, height: number) {
        const margin = Boid.BOID_SIZE * 0.5;
        if (this.position.x > width - margin || this.position.x < margin) {
            this.velocity.x *= -1;
        }
        if (this.position.y > height - margin || this.position.y < margin) {
            this.velocity.y *= -1;
        }
    }
}

Boid.vue

This is only being used by the old version of the swarm. It draws a SVG looking like an arrow.

<script setup lang="ts">
const props = defineProps({
  boid: {
    type: Boid,
    required: true
  }
});

const boid = ref<Boid>(props.boid);
const position = computed(() => boid.value.position);
const x = computed(() => position.value.x - Boid.BOID_SIZE / 2);
const y = computed(() => position.value.y - Boid.BOID_SIZE / 2);
</script>

<template>
  <svg class="absolute"  :style="{ transform: 'translate(' + x + 'px, ' + y + 'px) rotate(' + boid.rotation + 'deg)' }" xmlns="http://www.w3.org/2000/svg" :width="Boid.BOID_SIZE" :height="Boid.BOID_SIZE" viewBox="0 0 1280.000000 1280.000000" preserveAspectRatio="xMidYMid meet">
    <path transform="translate(0.000000,1280.000000) scale(0.100000,-0.100000)" fill="#000000" d="M299 12786 c-120 -35 -207 -110 -263 -226 -28 -59 -31 -73 -30 -160 0 -79 5 -104 26 -150 13 -30 894 -1359 1956 -2953 l1932 -2897 -1932 -2897 c-1062 -1594 -1943 -2923 -1957 -2953 -21 -47 -25 -70 -25 -150 0 -86 3 -101 30 -160 17 -36 50 -86 72 -111 79 -88 223 -140 347 -124 51 6 811 383 6125 3040 5803 2901 6069 3036 6110 3082 56 63 98 154 106 229 13 121 -39 260 -125 336 -35 31 -1509 772 -6106 3070 l-6060 3030 -80 4 c-48 2 -98 -2 -126 -10z"/>
  </svg>
</template>

Swarm.vue

This is the old swarm that is looping over all the boids and displaying the boid.vue for each.

<script setup lang="ts">
const boids = ref<Boid[]>([]);
const computedBoids = computed(() => boids.value);
const target = new Vector(0, 0)

const bounds = {
  width: 500,
  height: 500,
}

onMounted(() => {
  for (let i = 0; i < 10; i++) {
    boids.value.push(new Boid());
  }

  const updateBoids = setInterval(() => {
    computedBoids.value.forEach(boid => boid.update(bounds, computedBoids.value as Boid[]));
  }, 1000 / 120);

  // save mouse position as target
  const mouseMoveHandler = (event: any) => {
    target.x = event.clientX;
    target.y = event.clientY;
  };

  window.addEventListener('mousemove', mouseMoveHandler);

  onUnmounted(() => {
    clearInterval(updateBoids);
    window.removeEventListener('mousemove', mouseMoveHandler);
  });
});

</script>

<template>
  <div class="flex justify-center">
    <div class="bg-white border-2 border-black" :style="`width: ${bounds.width}px; height: ${bounds.height}px;`">
      <BoidComponent v-for="(boid, index) in computedBoids" :key="index" :boid="boid as Boid"/>
    </div>
  </div>
  <SwarmSettings :boids="computedBoids"/>
</template>

SwarmV2.vue

This is the new version of the swarm. There is a method that is using requestAnimationFrame to draw the changes. The method in requestAnimationFrame loops over all boids and calls their draw function.

<script setup lang="ts">

const boids = ref<Boid[]>([]);
const target = new Vector(0, 0);
const canvas = ref<HTMLCanvasElement>();
let ctx: CanvasRenderingContext2D | null;

const bounds = {
  width: 500,
  height: 500,
}


onMounted(() => {
  for (let i = 0; i < 10; i++) {
    boids.value.push(new Boid());
  }

  canvas.value = document.querySelector('canvas') as HTMLCanvasElement;
  ctx = canvas.value.getContext('2d');

  const updateBoids = setInterval(() => {
    boids.value.forEach(boid => boid.update(bounds, boids.value as Boid[]));
  }, 1000 / 120);

  const mouseMoveHandler = (event: any) => {
    target.x = event.clientX;
    target.y = event.clientY;
  };

  window.addEventListener('mousemove', mouseMoveHandler);

  onUnmounted(() => {
    clearInterval(updateBoids);
    window.removeEventListener('mousemove', mouseMoveHandler);
  });

  drawBoids();
});



const drawBoids = () => {
  ctx?.clearRect(0, 0, bounds.width, bounds.height);
  boids.value.forEach(boid => boid.draw(ctx as CanvasRenderingContext2D));
  requestAnimationFrame(drawBoids);
}


</script>

<template>
  <div class="flex justify-center pt-12">
    <canvas ref="canvas" class="bg-white border-2 border-black" :style="`width: ${bounds.width}px; height: ${bounds.height}px;`"></canvas>
  </div>
  <SwarmSettings :boids="boids"/>
</template>

Solution

  • There are two separate width/height values for a canvas:

      canvas.value = document.querySelector('canvas') as HTMLCanvasElement;
      canvas.value.width = bounds.width;
      canvas.value.height = bounds.height;