Canvas绘图

Canvas是HTML5提供的绘图元素,可以通过JavaScript在网页上绘制图形、动画和图像。本章将介绍Canvas的基本概念和2D绑图技术。

Canvas基础

创建Canvas

<canvas id="myCanvas" width="800" height="600">
    您的浏览器不支持Canvas
</canvas>
const canvas = document.getElementById('myCanvas');
const ctx = canvas.getContext('2d');

console.log(canvas.width);
console.log(canvas.height);

canvas.width = window.innerWidth;
canvas.height = window.innerHeight;

window.addEventListener('resize', () => {
    canvas.width = window.innerWidth;
    canvas.height = window.innerHeight;
});

Canvas状态

const canvasState = {
    save: '保存当前状态(变换矩阵、裁剪区域、样式属性)',
    restore: '恢复到上次保存的状态',
    
    stateStack: '状态以栈的形式存储,可以多次save/restore',
    
    example: `
        ctx.fillStyle = 'red';
        ctx.save();
        
        ctx.fillStyle = 'blue';
        ctx.translate(100, 100);
        ctx.save();
        
        ctx.fillStyle = 'green';
        ctx.translate(50, 50);
        
        ctx.restore();
        console.log(ctx.fillStyle);
        
        ctx.restore();
        console.log(ctx.fillStyle);
    `
};

绑制基本形状

矩形

ctx.fillStyle = 'red';
ctx.fillRect(10, 10, 100, 50);

ctx.strokeStyle = 'blue';
ctx.lineWidth = 2;
ctx.strokeRect(130, 10, 100, 50);

ctx.clearRect(20, 20, 80, 30);

路径

ctx.beginPath();
ctx.moveTo(50, 50);
ctx.lineTo(150, 50);
ctx.lineTo(150, 150);
ctx.closePath();

ctx.strokeStyle = 'green';
ctx.lineWidth = 3;
ctx.stroke();

ctx.fillStyle = 'rgba(0, 255, 0, 0.3)';
ctx.fill();

ctx.beginPath();
ctx.moveTo(200, 50);
ctx.lineTo(300, 150);
ctx.lineTo(200, 150);
ctx.closePath();
ctx.stroke();

圆形和弧线

ctx.beginPath();
ctx.arc(100, 100, 50, 0, Math.PI * 2);
ctx.fillStyle = 'orange';
ctx.fill();

ctx.beginPath();
ctx.arc(200, 100, 50, 0, Math.PI);
ctx.strokeStyle = 'purple';
ctx.lineWidth = 3;
ctx.stroke();

ctx.beginPath();
ctx.arc(300, 100, 50, 0, Math.PI, true);
ctx.stroke();

ctx.beginPath();
ctx.arc(100, 200, 50, 0, Math.PI * 2);
ctx.arc(100, 200, 30, 0, Math.PI * 2);
ctx.fillStyle = 'cyan';
ctx.fill('evenodd');

贝塞尔曲线

ctx.beginPath();
ctx.moveTo(50, 200);
ctx.quadraticCurveTo(100, 100, 200, 200);
ctx.strokeStyle = 'red';
ctx.stroke();

ctx.beginPath();
ctx.moveTo(250, 200);
ctx.bezierCurveTo(280, 100, 350, 300, 400, 200);
ctx.strokeStyle = 'blue';
ctx.stroke();

ctx.beginPath();
ctx.moveTo(50, 300);
ctx.lineTo(100, 300);
ctx.arcTo(150, 300, 150, 350, 50);
ctx.lineTo(150, 400);
ctx.stroke();

样式和颜色

填充和描边样式

ctx.fillStyle = 'red';
ctx.fillStyle = '#ff0000';
ctx.fillStyle = '#f00';
ctx.fillStyle = 'rgb(255, 0, 0)';
ctx.fillStyle = 'rgba(255, 0, 0, 0.5)';
ctx.fillStyle = 'hsl(0, 100%, 50%)';
ctx.fillStyle = 'hsla(0, 100%, 50%, 0.5)';

ctx.strokeStyle = 'blue';
ctx.lineWidth = 5;
ctx.lineCap = 'butt';
ctx.lineCap = 'round';
ctx.lineCap = 'square';

ctx.lineJoin = 'miter';
ctx.lineJoin = 'round';
ctx.lineJoin = 'bevel';

ctx.miterLimit = 10;

ctx.setLineDash([10, 5]);
ctx.lineDashOffset = 0;

渐变

const linearGradient = ctx.createLinearGradient(0, 0, 200, 0);
linearGradient.addColorStop(0, 'red');
linearGradient.addColorStop(0.5, 'yellow');
linearGradient.addColorStop(1, 'blue');

ctx.fillStyle = linearGradient;
ctx.fillRect(10, 10, 200, 100);

const radialGradient = ctx.createRadialGradient(100, 300, 10, 100, 300, 100);
radialGradient.addColorStop(0, 'white');
radialGradient.addColorStop(1, 'black');

ctx.fillStyle = radialGradient;
ctx.beginPath();
ctx.arc(100, 300, 100, 0, Math.PI * 2);
ctx.fill();

const conicGradient = ctx.createConicGradient(0, 300, 100);
conicGradient.addColorStop(0, 'red');
conicGradient.addColorStop(0.25, 'yellow');
conicGradient.addColorStop(0.5, 'green');
conicGradient.addColorStop(0.75, 'blue');
conicGradient.addColorStop(1, 'red');

ctx.fillStyle = conicGradient;
ctx.beginPath();
ctx.arc(300, 100, 80, 0, Math.PI * 2);
ctx.fill();

图案

const img = new Image();
img.onload = function() {
    const pattern = ctx.createPattern(img, 'repeat');
    ctx.fillStyle = pattern;
    ctx.fillRect(0, 0, canvas.width, canvas.height);
};
img.src = 'pattern.png';

const repeatModes = {
    repeat: '水平和垂直重复',
    'repeat-x': '仅水平重复',
    'repeat-y': '仅垂直重复',
    'no-repeat': '不重复'
};

文本绘制

基本文本

ctx.font = '48px Arial';
ctx.textBaseline = 'top';
ctx.textAlign = 'left';

ctx.fillStyle = 'black';
ctx.fillText('东巴文 Canvas教程', 50, 50);

ctx.strokeStyle = 'blue';
ctx.lineWidth = 1;
ctx.strokeText('描边文本', 50, 120);

const text = '测量文本宽度';
const metrics = ctx.measureText(text);
console.log(`文本宽度: ${metrics.width}px`);

ctx.font = '24px Arial';
const textWidth = ctx.measureText('居中文本').width;
const x = (canvas.width - textWidth) / 2;
ctx.fillText('居中文本', x, 200);

文本样式

ctx.font = 'italic bold 36px Georgia, serif';

ctx.textAlign = 'left';
ctx.textAlign = 'center';
ctx.textAlign = 'right';

ctx.textBaseline = 'top';
ctx.textBaseline = 'middle';
ctx.textBaseline = 'bottom';
ctx.textBaseline = 'alphabetic';
ctx.textBaseline = 'hanging';

ctx.direction = 'ltr';
ctx.direction = 'rtl';
ctx.direction = 'inherit';

图像处理

绘制图像

const img = new Image();
img.onload = function() {
    ctx.drawImage(img, 0, 0);
    
    ctx.drawImage(img, 0, 0, 200, 150);
    
    ctx.drawImage(img, 
        0, 0, 100, 100,
        250, 0, 200, 200
    );
};
img.src = 'image.jpg';

const video = document.querySelector('video');
function drawFrame() {
    ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
    requestAnimationFrame(drawFrame);
}
video.play();
drawFrame();

图像数据处理

const img = new Image();
img.onload = function() {
    ctx.drawImage(img, 0, 0);
    
    const imageData = ctx.getImageData(0, 0, img.width, img.height);
    const data = imageData.data;
    
    for (let i = 0; i < data.length; i += 4) {
        const avg = (data[i] + data[i + 1] + data[i + 2]) / 3;
        data[i] = avg;
        data[i + 1] = avg;
        data[i + 2] = avg;
    }
    
    ctx.putImageData(imageData, img.width + 10, 0);
};
img.src = 'photo.jpg';

function invertColors(imageData) {
    const data = imageData.data;
    for (let i = 0; i < data.length; i += 4) {
        data[i] = 255 - data[i];
        data[i + 1] = 255 - data[i + 1];
        data[i + 2] = 255 - data[i + 2];
    }
    return imageData;
}

function adjustBrightness(imageData, factor) {
    const data = imageData.data;
    for (let i = 0; i < data.length; i += 4) {
        data[i] = Math.min(255, data[i] * factor);
        data[i + 1] = Math.min(255, data[i + 1] * factor);
        data[i + 2] = Math.min(255, data[i + 2] * factor);
    }
    return imageData;
}

function createImageData(width, height) {
    const imageData = ctx.createImageData(width, height);
    const data = imageData.data;
    
    for (let i = 0; i < data.length; i += 4) {
        data[i] = Math.random() * 255;
        data[i + 1] = Math.random() * 255;
        data[i + 2] = Math.random() * 255;
        data[i + 3] = 255;
    }
    
    return imageData;
}

变换

基本变换

ctx.translate(100, 100);
ctx.fillRect(0, 0, 50, 50);

ctx.rotate(Math.PI / 4);
ctx.fillRect(0, 0, 50, 50);

ctx.scale(2, 0.5);
ctx.fillRect(0, 0, 50, 50);

ctx.setTransform(1, 0, 0, 1, 0, 0);

ctx.transform(1, 0, 0, 1, 100, 100);
ctx.transform(1, 0.5, 0, 1, 0, 0);

变换矩阵

const matrix = {
    description: '变换矩阵 [a, b, c, d, e, f]',
    format: `
        | a c e |
        | b d f |
        | 0 0 1 |
    `,
    
    parameters: {
        a: '水平缩放',
        b: '垂直倾斜',
        c: '水平倾斜',
        d: '垂直缩放',
        e: '水平位移',
        f: '垂直位移'
    }
};

function drawRotatedRect(x, y, width, height, angle) {
    ctx.save();
    
    ctx.translate(x + width / 2, y + height / 2);
    ctx.rotate(angle);
    ctx.translate(-width / 2, -height / 2);
    
    ctx.fillRect(0, 0, width, height);
    
    ctx.restore();
}

function drawSkewedRect(x, y, width, height, skewX, skewY) {
    ctx.save();
    
    ctx.setTransform(1, skewY, skewX, 1, x, y);
    
    ctx.fillRect(0, 0, width, height);
    
    ctx.restore();
}

动画

requestAnimationFrame

let animationId;
let x = 0;

function animate() {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    
    ctx.fillStyle = 'blue';
    ctx.fillRect(x, 100, 50, 50);
    
    x += 2;
    if (x > canvas.width) {
        x = -50;
    }
    
    animationId = requestAnimationFrame(animate);
}

animate();

function stopAnimation() {
    cancelAnimationFrame(animationId);
}

动画循环

class Animation {
    constructor(canvas) {
        this.canvas = canvas;
        this.ctx = canvas.getContext('2d');
        this.objects = [];
        this.isRunning = false;
        this.lastTime = 0;
    }
    
    add(object) {
        this.objects.push(object);
    }
    
    remove(object) {
        const index = this.objects.indexOf(object);
        if (index > -1) {
            this.objects.splice(index, 1);
        }
    }
    
    start() {
        if (this.isRunning) return;
        this.isRunning = true;
        this.lastTime = performance.now();
        this.loop();
    }
    
    stop() {
        this.isRunning = false;
    }
    
    loop() {
        if (!this.isRunning) return;
        
        const currentTime = performance.now();
        const deltaTime = (currentTime - this.lastTime) / 1000;
        this.lastTime = currentTime;
        
        this.update(deltaTime);
        this.render();
        
        requestAnimationFrame(() => this.loop());
    }
    
    update(deltaTime) {
        this.objects.forEach(obj => {
            if (obj.update) {
                obj.update(deltaTime);
            }
        });
    }
    
    render() {
        this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
        
        this.objects.forEach(obj => {
            if (obj.render) {
                obj.render(this.ctx);
            }
        });
    }
}

class Ball {
    constructor(x, y, radius, color, vx, vy) {
        this.x = x;
        this.y = y;
        this.radius = radius;
        this.color = color;
        this.vx = vx;
        this.vy = vy;
        this.gravity = 200;
    }
    
    update(deltaTime) {
        this.vy += this.gravity * deltaTime;
        
        this.x += this.vx * deltaTime;
        this.y += this.vy * deltaTime;
    }
    
    render(ctx) {
        ctx.beginPath();
        ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
        ctx.fillStyle = this.color;
        ctx.fill();
    }
}

const animation = new Animation(canvas);
animation.add(new Ball(100, 100, 20, 'red', 100, 0));
animation.start();

交互

鼠标事件

let isDrawing = false;
let lastX = 0;
let lastY = 0;

canvas.addEventListener('mousedown', (e) => {
    isDrawing = true;
    [lastX, lastY] = [e.offsetX, e.offsetY];
});

canvas.addEventListener('mousemove', (e) => {
    if (!isDrawing) return;
    
    ctx.beginPath();
    ctx.moveTo(lastX, lastY);
    ctx.lineTo(e.offsetX, e.offsetY);
    ctx.strokeStyle = 'black';
    ctx.lineWidth = 2;
    ctx.stroke();
    
    [lastX, lastY] = [e.offsetX, e.offsetY];
});

canvas.addEventListener('mouseup', () => {
    isDrawing = false;
});

canvas.addEventListener('mouseleave', () => {
    isDrawing = false;
});

function getMousePos(e) {
    const rect = canvas.getBoundingClientRect();
    return {
        x: e.clientX - rect.left,
        y: e.clientY - rect.top
    };
}

触摸事件

canvas.addEventListener('touchstart', (e) => {
    e.preventDefault();
    const touch = e.touches[0];
    const pos = getTouchPos(touch);
    
    isDrawing = true;
    [lastX, lastY] = [pos.x, pos.y];
});

canvas.addEventListener('touchmove', (e) => {
    e.preventDefault();
    if (!isDrawing) return;
    
    const touch = e.touches[0];
    const pos = getTouchPos(touch);
    
    ctx.beginPath();
    ctx.moveTo(lastX, lastY);
    ctx.lineTo(pos.x, pos.y);
    ctx.stroke();
    
    [lastX, lastY] = [pos.x, pos.y];
});

canvas.addEventListener('touchend', () => {
    isDrawing = false;
});

function getTouchPos(touch) {
    const rect = canvas.getBoundingClientRect();
    return {
        x: touch.clientX - rect.left,
        y: touch.clientY - rect.top
    };
}

碰撞检测

function pointInCircle(px, py, cx, cy, radius) {
    const dx = px - cx;
    const dy = py - cy;
    return dx * dx + dy * dy <= radius * radius;
}

function pointInRect(px, py, rx, ry, rw, rh) {
    return px >= rx && px <= rx + rw && py >= ry && py <= ry + rh;
}

function circleCollision(c1, c2) {
    const dx = c2.x - c1.x;
    const dy = c2.y - c1.y;
    const distance = Math.sqrt(dx * dx + dy * dy);
    return distance < c1.radius + c2.radius;
}

function rectCollision(r1, r2) {
    return r1.x < r2.x + r2.width &&
           r1.x + r1.width > r2.x &&
           r1.y < r2.y + r2.height &&
           r1.y + r1.height > r2.y;
}

const objects = [
    { type: 'circle', x: 100, y: 100, radius: 30, color: 'red' },
    { type: 'rect', x: 200, y: 150, width: 60, height: 40, color: 'blue' }
];

canvas.addEventListener('click', (e) => {
    const pos = getMousePos(e);
    
    for (const obj of objects) {
        let hit = false;
        
        if (obj.type === 'circle') {
            hit = pointInCircle(pos.x, pos.y, obj.x, obj.y, obj.radius);
        } else if (obj.type === 'rect') {
            hit = pointInRect(pos.x, pos.y, obj.x, obj.y, obj.width, obj.height);
        }
        
        if (hit) {
            console.log('点击了对象:', obj);
        }
    }
});

导出图像

function saveAsPNG() {
    const dataURL = canvas.toDataURL('image/png');
    const link = document.createElement('a');
    link.download = 'canvas-image.png';
    link.href = dataURL;
    link.click();
}

function saveAsJPEG(quality = 0.9) {
    const dataURL = canvas.toDataURL('image/jpeg', quality);
    const link = document.createElement('a');
    link.download = 'canvas-image.jpg';
    link.href = dataURL;
    link.click();
}

async function saveAsBlob() {
    return new Promise((resolve) => {
        canvas.toBlob((blob) => {
            resolve(blob);
        }, 'image/png');
    });
}

const blob = await saveAsBlob();
const formData = new FormData();
formData.append('image', blob, 'canvas.png');
await fetch('/upload', { method: 'POST', body: formData });

东巴文小贴士

🎨 Canvas性能优化

  1. 减少状态切换:批量绘制相同样式的图形
  2. 离屏Canvas:复杂图形先在离屏Canvas绘制
  3. 避免频繁清除:只清除需要更新的区域
  4. 使用requestAnimationFrame:保证动画流畅
  5. 合理使用save/restore:减少不必要的状态保存

📐 坐标系说明

Canvas坐标系原点在左上角:

  • X轴向右为正
  • Y轴向下为正
  • 角度以弧度为单位,顺时针方向

下一步

下一章将探讨 WebGL基础,学习如何在浏览器中进行3D图形渲染。