Как реализовать эффект (исчезающего следа) как в игре dark echo

Я хочу сделать эффект визуального звука, который отражается от стен и постепенно затухает (как в игре dark echo).
Каким образом я могу это сделать средствами canvas?
введите сюда описание изображения

Проблема в следующем:
Если я буду делать через создание полупрозрачных копий частицы, то и это слишком будет тормозить отрисовку (след нужен довольно длинный)

Движение WASD или стрелки
Пробел - создать звук

<!DOCTYPE html>
<html>
<head>
    <title>шлейф</title>
    <meta charset="utf-8">
    <style>
        canvas {
            border: 2px solid grey;
        }
    </style>
    <script
        src="https://code.jquery.com/jquery-3.4.1.min.js"
        integrity="sha256-CSXorXvZcTkaix6Yvo6HppcZGetbYMGWSFlBw8HfCJo="
        crossorigin="anonymous">
    </script>
</head>
<body>
<canvas id="canvas" width="200" height="200"></canvas>
<script type="text/javascript">
    var game_status = 'active';

    var x_cam = 0;
    var y_cam = 0;

    var direction = {
        "37": "left",
        "38": "up",
        "39": "right",
        "40": "down",
        "65": "left",
        "87": "up",
        "68": "right",
        "83": "down",
    };

    
    var ctx = document.getElementById('canvas').getContext('2d');

    var map = {
        ctx: ctx,
        width: 200,
        height: 200,
        particles: [],
        circle: function (x, y, radius, color, fillCircle=true) {
            this.ctx.beginPath();
            this.ctx.arc(x, y, radius, 0, Math.PI * 2, false);
            if (fillCircle) {
                this.ctx.fillStyle = color; 
                this.ctx.fill();
            } else {
                this.ctx.strokeStyle = color;
                this.ctx.stroke();
            }
        },
        clear: function() {
            this.ctx.clearRect(0, 0, this.width, this.height)
        },
        alpha_clear: function(alpha) {
            this.ctx.fillStyle = "rgba(255, 255, 255,"+alpha+")"
            this.ctx.fillRect(0, 0, this.width, this.height);
        }
    }

    var player = {
        x: 100,
        y: 100,
        speed: 2,
        x_vec: 0,
        y_vec: 0,
        angle: 0,
        direction: {
            "up": false,
            "down": false,
            "right": false,
            "left": false
        },
        setDirection: function(dir, status) {
            if (dir == 'up' || dir == 'down' || dir == 'right' || dir == 'left') {
                this.direction[dir] = status;
            }
        },
        setVecs: function() {
            this.x_vec = 0;
            this.y_vec = 0;

            if (this.direction.up) {
                this.y_vec--;
            }

            if (this.direction.down) {
                this.y_vec++;
            }

            if (this.direction.right) {
                this.x_vec++;
            }

            if (this.direction.left) {
                this.x_vec--;
            }

            if (Math.abs(this.x_vec) > 0 && Math.abs(this.y_vec) > 0) {
                this.x_vec *= 0.7;
                this.y_vec *= 0.7;
            }

            // строго наверх
            if (this.x_vec == 0 && this.y_vec == -1) {
                this.angle = 0
            }

            // строго вниз
            if (this.x_vec == 0 && this.y_vec == 1) {
                this.angle = Math.PI;
            }

            // строго вправо
            if (this.x_vec == 1 && this.y_vec == 0) {
                this.angle = Math.PI/2;
            }

            // строго влево
            if (this.x_vec == -1 && this.y_vec == 0) {
                this.angle = Math.PI*1.5;
            }

            // наверх-вправо
            if (this.x_vec == 0.7 && this.y_vec == -0.7) {
                this.angle = Math.PI/4;
            }

            // вниз-вправо
            if (this.x_vec == 0.7 && this.y_vec == 0.7) {
                this.angle = Math.PI*0.75;
            }

            // вниз-влево
            if (this.x_vec == -0.7 && this.y_vec == 0.7) {
                this.angle = Math.PI*1.25;
            }

            // вверх-влево
            if (this.x_vec == -0.7 && this.y_vec == -0.7) {
                this.angle = Math.PI*1.75;
            }
        },
        draw: function() {
            map.circle(this.x-x_cam, this.y-y_cam, 10, "red");
        },
        update: function() {
            this.x += this.x_vec*this.speed;
            this.y += this.y_vec*this.speed;
            x_cam += this.x_vec*this.speed;
            y_cam += this.y_vec*this.speed;
        },
        createSound: function() {
            var angle_step = Math.PI*2/10;
            for (var i=0; i < 10; i++) {
                map.particles.push(new Particle(this.x, this.y, i*angle_step));

            }
        }
    }

    var Particle = function(x=300, y=300, angle=0) {
        this.x= x;
        this.y= y;
        this.angle = angle;
        this.x_vec= Math.cos(angle);
        this.y_vec= Math.sin(angle);
        this.speed = 5;
        this.tail = [];
        for (var i=0; i < 20; i++) {
            this.tail.push({
                "x": x,
                "y": y,

            })
        }
        this.draw= function() {
            map.circle(this.x-x_cam, this.y-y_cam, 3, 'black');
            for (var i=0; i < this.tail.length; i++) {
                map.circle(this.tail[i].x-x_cam, this.tail[i].y-y_cam, 3, 'black');
            
            }
        };
        this.update= function() {
            this.x += this.x_vec*this.speed;
            this.y += this.y_vec*this.speed;
        }
        this.update_tail = function() {
            for (var j=this.tail.length-1; j > 0; j--) {
                    this.tail[j] = this.tail[j-1];

                
            }
            this.tail.pop();
            this.tail.unshift({
                "x": this.x,
                "y": this.y,
            })
            //this.tail[0].x = this.x;
            //this.tail[0].y = this.y;
        }

    }

    $("body").keydown(function (event) {
    //console.log(snake);
        if (direction[event.keyCode] != undefined) {
            var pl_direction = direction[event.keyCode];
            player.setDirection(pl_direction, true);
        } else {
            if (event.keyCode== 13) {
                if (game_status == 'active') {
                    game_status = 'pause';
                } else {
                    game_status = 'active';
                }
            } else if (event.keyCode == 32) {
                player.createSound();
            }
        }


    });

    $("body").keyup(function (event) {
    //console.log(snake);
        if (direction[event.keyCode] != undefined) {
            var pl_direction = direction[event.keyCode];
            player.setDirection(pl_direction, false);
        }
    });

    var particle = new Particle();

    var game_loop = function() {
        if (game_status == 'active') {
            window.requestAnimationFrame(game_loop);

            map.clear();
            ctx.fillStyle = "black";
            ctx.fillRect(100-x_cam, 100-y_cam, 100, 100);
            player.setVecs();
            player.update();
            for (var i in map.particles) {
                map.particles[i].update_tail();
                map.particles[i].update();
                map.particles[i].draw()
            }
            particle.update_tail();
            particle.update();
            particle.draw();
            player.draw();
        }
    }

    game_loop();
</script>
</body>
</html>
В примере выше я не стал добавлять полупрозрачность частиц, т.к. и так понятно, что такое кол-во частиц нагружает систему
Однако если я буду неполностью стирать canvas, то во время движения линии будут искажаться (я для имитации viewport (движения камеры) отнимаю условную позицию камеры). Это хорошо видно, если стрелками туда-сюда двигаться

Движение WASD или стрелки
Пробел - создать звук

<!DOCTYPE html>
<html>
<head>
    <title>шлейф</title>
    <meta charset="utf-8">
    <style>
        canvas {
            border: 2px solid grey;
        }
    </style>
    <script
        src="https://code.jquery.com/jquery-3.4.1.min.js"
        integrity="sha256-CSXorXvZcTkaix6Yvo6HppcZGetbYMGWSFlBw8HfCJo="
        crossorigin="anonymous">
    </script>
</head>
<body>
<canvas id="canvas" width="200" height="200"></canvas>
<script type="text/javascript">
    var game_status = 'active';

    var x_cam = 0;
    var y_cam = 0;

    var direction = {
        "37": "left",
        "38": "up",
        "39": "right",
        "40": "down",
        "65": "left",
        "87": "up",
        "68": "right",
        "83": "down",
    };

    
    var ctx = document.getElementById('canvas').getContext('2d');

    var map = {
        ctx: ctx,
        width: 200,
        height: 200,
        particles: [],
        circle: function (x, y, radius, color, fillCircle=true) {
            this.ctx.beginPath();
            this.ctx.arc(x, y, radius, 0, Math.PI * 2, false);
            if (fillCircle) {
                this.ctx.fillStyle = color; 
                this.ctx.fill();
            } else {
                this.ctx.strokeStyle = color;
                this.ctx.stroke();
            }
        },
        clear: function() {
            this.ctx.clearRect(0, 0, this.width, this.height)
        },
        alpha_clear: function(alpha) {
            this.ctx.fillStyle = "rgba(255, 255, 255,"+alpha+")"
            this.ctx.fillRect(0, 0, this.width, this.height);
        }
    }

    var player = {
        x: 100,
        y: 100,
        speed: 3,
        x_vec: 0,
        y_vec: 0,
        angle: 0,
        direction: {
            "up": false,
            "down": false,
            "right": false,
            "left": false
        },
        setDirection: function(dir, status) {
            if (dir == 'up' || dir == 'down' || dir == 'right' || dir == 'left') {
                this.direction[dir] = status;
            }
        },
        setVecs: function() {
            this.x_vec = 0;
            this.y_vec = 0;

            if (this.direction.up) {
                this.y_vec--;
            }

            if (this.direction.down) {
                this.y_vec++;
            }

            if (this.direction.right) {
                this.x_vec++;
            }

            if (this.direction.left) {
                this.x_vec--;
            }

            if (Math.abs(this.x_vec) > 0 && Math.abs(this.y_vec) > 0) {
                this.x_vec *= 0.7;
                this.y_vec *= 0.7;
            }

            // строго наверх
            if (this.x_vec == 0 && this.y_vec == -1) {
                this.angle = 0
            }

            // строго вниз
            if (this.x_vec == 0 && this.y_vec == 1) {
                this.angle = Math.PI;
            }

            // строго вправо
            if (this.x_vec == 1 && this.y_vec == 0) {
                this.angle = Math.PI/2;
            }

            // строго влево
            if (this.x_vec == -1 && this.y_vec == 0) {
                this.angle = Math.PI*1.5;
            }

            // наверх-вправо
            if (this.x_vec == 0.7 && this.y_vec == -0.7) {
                this.angle = Math.PI/4;
            }

            // вниз-вправо
            if (this.x_vec == 0.7 && this.y_vec == 0.7) {
                this.angle = Math.PI*0.75;
            }

            // вниз-влево
            if (this.x_vec == -0.7 && this.y_vec == 0.7) {
                this.angle = Math.PI*1.25;
            }

            // вверх-влево
            if (this.x_vec == -0.7 && this.y_vec == -0.7) {
                this.angle = Math.PI*1.75;
            }
        },
        draw: function() {
            map.circle(this.x-x_cam, this.y-y_cam, 10, "red");
        },
        update: function() {
            this.x += this.x_vec*this.speed;
            this.y += this.y_vec*this.speed;
            x_cam += this.x_vec*this.speed;
            y_cam += this.y_vec*this.speed;
        },
        createSound: function() {
            var angle_step = Math.PI*2/10;
            for (var i=0; i < 10; i++) {
                map.particles.push(new Particle(this.x, this.y, i*angle_step));

            }
        }
    }

    var Particle = function(x=300, y=300, angle=0) {
        this.x= x;
        this.y= y;
        this.angle = angle;
        this.x_vec= Math.cos(angle);
        this.y_vec= Math.sin(angle);
        this.speed = 5;
        this.tail = [];
        for (var i=0; i < 20; i++) {
            this.tail.push({
                "x": x,
                "y": y,

            })
        }
        this.draw= function() {
            map.circle(this.x-x_cam, this.y-y_cam, 3, 'black');
            /*for (var i=0; i < this.tail.length; i++) {
                map.circle(this.tail[i].x-x_cam, this.tail[i].y-y_cam, 3, 'black');
            
            }*/
        };
        this.update= function() {
            this.x += this.x_vec*this.speed;
            this.y += this.y_vec*this.speed;
        }
        this.update_tail = function() {
            for (var j=this.tail.length-1; j > 0; j--) {
                    this.tail[j] = this.tail[j-1];

                
            }
            this.tail.pop();
            this.tail.unshift({
                "x": this.x,
                "y": this.y,
            })
            //this.tail[0].x = this.x;
            //this.tail[0].y = this.y;
        }

    }

    $("body").keydown(function (event) {
    //console.log(snake);
        if (direction[event.keyCode] != undefined) {
            var pl_direction = direction[event.keyCode];
            player.setDirection(pl_direction, true);
        } else {
            if (event.keyCode== 13) {
                if (game_status == 'active') {
                    game_status = 'pause';
                } else {
                    game_status = 'active';
                }
            } else if (event.keyCode == 32) {
                player.createSound();
            }
        }


    });

    $("body").keyup(function (event) {
    //console.log(snake);
        if (direction[event.keyCode] != undefined) {
            var pl_direction = direction[event.keyCode];
            player.setDirection(pl_direction, false);
        }
    });

    var particle = new Particle();

    var game_loop = function() {
        if (game_status == 'active') {
            window.requestAnimationFrame(game_loop);

            map.alpha_clear(0.15);
            ctx.fillStyle = "black";
            ctx.fillRect(100-x_cam, 100-y_cam, 100, 100);
            player.setVecs();
            player.update();
            for (var i in map.particles) {
                //map.particles[i].update_tail();
                map.particles[i].update();
                map.particles[i].draw()
            }
            //particle.update_tail();
            particle.update();
            particle.draw();
            player.draw();
        }
    }

    game_loop();
</script>
</body>
</html>

Мне обязательно нужно имитировать viewport, т.к. вся карта не помещается в экране
Вопрос: как сделать такой эффект, чтобы он не был сильно тяжёлым и не тормозил бразуер, но при этом выглядел достойно (не как в code shippet №2)


Ответы (0 шт):