Canvas(03):补充示例
补充示例
<h4>001-basic-shapes.html</h4>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>【1】基础图形绘制</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 20px; background: #f8f9fa; color: #333; }
.demo-container { max-width: 900px; margin: 0 auto; background: white; padding: 24px; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.demo-title { margin-bottom: 16px; font-size: 18px; color: #555; border-bottom: 2px solid #007bff; padding-bottom: 8px; }
canvas {
display: block;
margin: 16px auto;
border: 1px solid #ddd;
border-radius: 8px;
background: white;
}
.controls {
display: flex;
gap: 12px;
justify-content: center;
flex-wrap: wrap;
margin-bottom: 16px;
}
.btn {
padding: 8px 20px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.3s;
}
.btn-primary { background: #007bff; color: white; }
.btn-primary:hover { background: #0056b3; }
.btn-secondary { background: #6c757d; color: white; }
.btn-secondary:hover { background: #5a6268; }
</style>
</head>
<body>
<div class="demo-container">
<div class="demo-title">示例:Canvas 基础图形绘制(矩形、圆形、三角形、贝塞尔曲线)</div>
<div class="controls">
<button class="btn btn-primary" onclick="drawAll()">🎨 绘制全部图形</button>
<button class="btn btn-secondary" onclick="clearCanvas()">🗑️ 清空画布</button>
</div>
<canvas id="myCanvas" width="850" height="550"></canvas>
</div>
<script>
const canvas = document.getElementById("myCanvas")
const ctx = canvas.getContext("2d")
function clearCanvas() {
ctx.clearRect(0, 0, canvas.width, canvas.height)
}
function drawAll() {
clearCanvas()
// ====== 1. 矩形 ======
ctx.fillStyle = "#ff6b6b"
ctx.fillRect(30, 30, 140, 100)
ctx.strokeStyle = "#333"
ctx.lineWidth = 2
ctx.strokeRect(200, 30, 140, 100)
// 描边填充矩形
ctx.fillStyle = "#4ecdc4"
ctx.fillRect(370, 30, 140, 100)
ctx.strokeRect(370, 30, 140, 100)
// 圆角矩形
drawRoundedRect(ctx, 540, 30, 140, 100, 15, "#45b7d1")
// 标签
ctx.fillStyle = "#666"
ctx.font = "13px sans-serif"
ctx.fillText("fillRect", 70, 150)
ctx.fillText("strokeRect", 240, 150)
ctx.fillText("Fill + Stroke", 400, 150)
ctx.fillText("圆角矩形", 580, 150)
// ====== 2. 三角形 ======
ctx.beginPath()
ctx.moveTo(100, 220)
ctx.lineTo(200, 320)
ctx.lineTo(50, 320)
ctx.closePath()
ctx.fillStyle = "#f39c12"
ctx.fill()
ctx.beginPath()
ctx.moveTo(250, 220)
ctx.lineTo(350, 320)
ctx.lineTo(200, 320)
ctx.closePath()
ctx.strokeStyle = "#e74c3c"
ctx.lineWidth = 3
ctx.stroke()
// ====== 3. 圆形与弧线 ======
ctx.beginPath()
ctx.arc(450, 270, 60, 0, Math.PI * 2)
ctx.fillStyle = "#3498db"
ctx.fill()
ctx.beginPath()
ctx.arc(600, 270, 60, 0, Math.PI, false)
ctx.strokeStyle = "#9b59b6"
ctx.lineWidth = 4
ctx.lineCap = "round"
ctx.stroke()
ctx.beginPath()
ctx.arc(750, 270, 60, 0, Math.PI * 1.5)
ctx.fillStyle = "rgba(46, 204, 113, 0.6)"
ctx.fill()
ctx.strokeStyle = "#27ae60"
ctx.lineWidth = 2
ctx.stroke()
// 标签
ctx.fillStyle = "#666"
ctx.font = "13px sans-serif"
ctx.fillText("完整圆", 420, 350)
ctx.fillText("半圆 (stroke)", 575, 350)
ctx.fillText("扇形 (3/4)", 720, 350)
// ====== 4. 椭圆 ======
ctx.beginPath()
ctx.ellipse(120, 450, 80, 50, 0, 0, Math.PI * 2)
ctx.fillStyle = "#1abc9c"
ctx.fill()
ctx.beginPath()
ctx.ellipse(300, 450, 80, 50, Math.PI / 6, 0, Math.PI * 2)
ctx.fillStyle = "#e67e22"
ctx.fill()
// ====== 5. 贝塞尔曲线 ======
// 二次贝塞尔曲线
ctx.beginPath()
ctx.moveTo(430, 480)
ctx.quadraticCurveTo(500, 400, 570, 480)
ctx.strokeStyle = "#8e44ad"
ctx.lineWidth = 3
ctx.stroke()
// 绘制控制点
ctx.fillStyle = "#8e44ad"
ctx.beginPath()
ctx.arc(500, 400, 5, 0, Math.PI * 2)
ctx.fill()
ctx.font = "11px monospace"
ctx.fillText("控制点", 485, 395)
// 三次贝塞尔曲线
ctx.beginPath()
ctx.moveTo(620, 480)
ctx.bezierCurveTo(670, 400, 750, 520, 820, 450)
ctx.strokeStyle = "#2980b9"
ctx.lineWidth = 3
ctx.stroke()
// 控制点
ctx.fillStyle = "#2980b9"
ctx.beginPath()
ctx.arc(670, 400, 5, 0, Math.PI * 2)
ctx.fill()
ctx.beginPath()
ctx.arc(750, 520, 5, 0, Math.PI * 2)
ctx.fill()
// 标签
ctx.fillStyle = "#666"
ctx.font = "13px sans-serif"
ctx.fillText("椭圆", 95, 520)
ctx.fillText("旋转椭圆", 260, 520)
ctx.fillText("二次贝塞尔", 465, 510)
ctx.fillText("三次贝塞尔", 685, 510)
// ====== 6. 虚线样式 ======
ctx.setLineDash([10, 5])
ctx.beginPath()
ctx.rect(30, 380, 160, 90)
ctx.strokeStyle = "#e74c3c"
ctx.lineWidth = 2
ctx.stroke()
ctx.setLineDash([]) // 恢复
ctx.setLineDash([15, 5, 5, 5])
ctx.beginPath()
ctx.rect(210, 380, 160, 90)
ctx.strokeStyle = "#2ecc71"
ctx.lineWidth = 2
ctx.stroke()
ctx.setLineDash([])
ctx.font = "12px monospace"
ctx.fillStyle = "#999"
ctx.fillText("[10, 5]", 85, 485)
ctx.fillText("[15, 5, 5, 5]", 250, 485)
}
function drawRoundedRect(ctx, x, y, width, height, radius, color) {
ctx.beginPath()
ctx.moveTo(x + radius, y)
ctx.lineTo(x + width - radius, y)
ctx.quadraticCurveTo(x + width, y, x + width, y + radius)
ctx.lineTo(x + width, y + height - radius)
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height)
ctx.lineTo(x + radius, y + height)
ctx.quadraticCurveTo(x, y + height, x, y + height - radius)
ctx.lineTo(x, y + radius)
ctx.quadraticCurveTo(x, y, x + radius, y)
ctx.closePath()
ctx.fillStyle = color
ctx.fill()
}
// 初始绘制
drawAll()
</script>
</body>
</html>
<h4>006-text-rendering.html</h4>
<!-- 来源:12-Canvas.md - 第8章 文本排版引擎 -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>【6】Canvas 文本绘制</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 20px; background: #f8f9fa; color: #333; }
.demo-container { max-width: 900px; margin: 0 auto; background: white; padding: 24px; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.demo-title { margin-bottom: 16px; font-size: 18px; color: #555; border-bottom: 2px solid #007bff; padding-bottom: 8px; }
canvas { display: block; margin: 16px auto; border: 1px solid #ddd; border-radius: 8px; background: white; }
.controls { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; margin-bottom: 16px; }
.btn {
padding: 8px 18px; border: none; border-radius: 6px; cursor: pointer;
font-size: 13px; font-weight: 500; transition: all 0.3s;
background: #007bff; color: white;
}
.btn:hover { background: #0056b3; }
.btn-secondary { background: #6c757d; }
.btn-secondary:hover { background: #5a6268; }
.section-label { text-align: center; font-size: 13px; color: #888; margin-top: 10px; }
</style>
</head>
<body>
<div class="demo-container">
<div class="demo-title">示例:Canvas 文本绘制(fillText / strokeText / textAlign / textBaseline / measureText)</div>
<div class="controls">
<button class="btn" onclick="drawAll()">🎨 绘制全部示例</button>
<button class="btn btn-secondary" onclick="clearCanvas()">🗑️ 清空画布</button>
</div>
<canvas id="textCanvas" width="850" height="700"></canvas>
<p class="section-label">💡 可交互:点击按钮查看不同文本绘制效果</p>
</div>
<script>
const canvas = document.getElementById("textCanvas")
const ctx = canvas.getContext("2d")
function clearCanvas() {
ctx.clearRect(0, 0, canvas.width, canvas.height)
}
function drawAll() {
clearCanvas()
drawBasicText()
drawTextAlignDemo()
drawTextBaselineDemo()
drawStrokeTextDemo()
drawMeasureTextDemo()
drawWrappedTextDemo()
drawAdvancedTextProps()
}
// ====== 1. 基本文本绘制 ======
function drawBasicText() {
ctx.save()
ctx.fillStyle = "#2c3e50"
ctx.font = "bold 24px 'Helvetica Neue', sans-serif"
ctx.fillText("Hello Canvas 文本绘制", 30, 40)
ctx.font = "16px monospace"
ctx.fillStyle = "#666"
ctx.fillText("fillText() — 填充文本", 30, 65)
ctx.restore()
// 分隔线
ctx.beginPath()
ctx.moveTo(20, 80)
ctx.lineTo(830, 80)
ctx.strokeStyle = "#eee"
ctx.lineWidth = 1
ctx.stroke()
}
// ====== 2. textAlign 对齐方式 ======
function drawTextAlignDemo() {
ctx.save()
ctx.fillStyle = "#333"
ctx.font = "bold 14px sans-serif"
ctx.fillText("textAlign 对齐方式演示", 30, 105)
const centerX = 450
const alignments = ["left", "center", "right"]
const colors = ["#e74c3c", "#3498db", "#2ecc71"]
alignments.forEach((align, i) => {
const y = 135 + i * 32
ctx.textAlign = align
ctx.fillStyle = colors[i]
ctx.font = "18px Arial"
ctx.fillText(`textAlign: ${align}`, centerX, y)
// 参考线
ctx.strokeStyle = "#999"
ctx.lineWidth = 1
ctx.setLineDash([4, 4])
ctx.beginPath()
ctx.moveTo(centerX, y - 18)
ctx.lineTo(centerX, y + 8)
ctx.stroke()
ctx.setLineDash([])
})
ctx.textAlign = "left"
ctx.restore()
// 分隔线
ctx.beginPath()
ctx.moveTo(20, 240)
ctx.lineTo(830, 240)
ctx.strokeStyle = "#eee"
ctx.lineWidth = 1
ctx.stroke()
}
// ====== 3. textBaseline 基线对齐 ======
function drawTextBaselineDemo() {
ctx.save()
ctx.fillStyle = "#333"
ctx.font = "bold 14px sans-serif"
ctx.fillText("textBaseline 基线对齐演示", 30, 265)
const baseY = 330
const baselines = [
{ name: "top", value: "top", color: "#e74c3c" },
{ name: "hanging", value: "hanging", color: "#f39c12" },
{ name: "middle", value: "middle", color: "#2ecc71" },
{ name: "alphabetic", value: "alphabetic", color: "#3498db" },
{ name: "bottom", value: "bottom", color: "#9b59b6" },
]
// 基准线
ctx.strokeStyle = "#e74c3c"
ctx.lineWidth = 1.5
ctx.setLineDash([6, 3])
ctx.beginPath()
ctx.moveTo(30, baseY)
ctx.lineTo(600, baseY)
ctx.stroke()
ctx.setLineDash([])
ctx.font = "14px Arial"
ctx.fillStyle = "#e74c3c"
ctx.font = "11px monospace"
ctx.fillText("基准线 (y=" + baseY + ")", 605, baseY + 4)
baselines.forEach((item, i) => {
const x = 50 + i * 115
ctx.textBaseline = item.value
ctx.fillStyle = item.color
ctx.font = "13px Arial"
ctx.fillText("Canvas", x, baseY)
// 标签
ctx.textBaseline = "top"
ctx.font = "10px monospace"
ctx.fillStyle = "#888"
ctx.fillText(item.name, x, baseY + 10)
})
ctx.textBaseline = "alphabetic"
ctx.restore()
// 分隔线
ctx.beginPath()
ctx.moveTo(20, 370)
ctx.lineTo(830, 370)
ctx.strokeStyle = "#eee"
ctx.lineWidth = 1
ctx.stroke()
}
// ====== 4. 描边文本 ======
function drawStrokeTextDemo() {
ctx.save()
ctx.fillStyle = "#333"
ctx.font = "bold 14px sans-serif"
ctx.fillText("strokeText 描边文本演示", 30, 395)
// 填充文本
ctx.font = "bold 28px Arial"
ctx.fillStyle = "#3498db"
ctx.fillText("填充文本 FillText", 50, 435)
// 描边文本
ctx.strokeStyle = "#e74c3c"
ctx.lineWidth = 1.5
ctx.strokeText("描边文本 StrokeText", 50, 470)
// 描边+填充组合
ctx.font = "bold 26px Arial"
ctx.fillStyle = "#fff"
ctx.strokeStyle = "#9b59b6"
ctx.lineWidth = 2.5
ctx.strokeText("描边+填充 组合效果", 50, 510)
ctx.fillText("描边+填充 组合效果", 50, 510)
ctx.restore()
// 分隔线
ctx.beginPath()
ctx.moveTo(20, 535)
ctx.lineTo(830, 535)
ctx.strokeStyle = "#eee"
ctx.lineWidth = 1
ctx.stroke()
}
// ====== 5. measureText 文本测量 ======
function drawMeasureTextDemo() {
ctx.save()
ctx.fillStyle = "#333"
ctx.font = "bold 14px sans-serif"
ctx.fillText("measureText 文本测量", 30, 560)
const text = "测量这段文字的宽度"
ctx.font = "20px 'Microsoft YaHei', sans-serif"
const metrics = ctx.measureText(text)
// 绘制文本
ctx.fillStyle = "#2c3e50"
ctx.fillText(text, 50, 595)
// 绘制宽度标注
const startX = 50
const endX = startX + metrics.width
ctx.strokeStyle = "#e74c3c"
ctx.lineWidth = 1
ctx.setLineDash([3, 3])
ctx.beginPath()
ctx.moveTo(startX, 605)
ctx.lineTo(endX, 605)
ctx.stroke()
ctx.setLineDash([])
// 两端竖线
ctx.beginPath()
ctx.moveTo(startX, 600)
ctx.lineTo(startX, 612)
ctx.moveTo(endX, 600)
ctx.lineTo(endX, 612)
ctx.stroke()
// 宽度数值
ctx.font = "12px monospace"
ctx.fillStyle = "#e74c3c"
ctx.textAlign = "center"
ctx.fillText(`width: ${metrics.width.toFixed(1)}px`, (startX + endX) / 2, 622)
// 显示更多属性
ctx.textAlign = "left"
ctx.font = "11px monospace"
ctx.fillStyle="#666"
const props = [
`actualBoundingBoxAscent: ${metrics.actualBoundingBoxAscent?.toFixed(1) ?? 'N/A'}`,
`actualBoundingBoxDescent: ${metrics.actualBoundingBoxDescent?.toFixed(1) ?? 'N/A'}`,
]
props.forEach((p, i) => { ctx.fillText(p, 450, 585 + i * 16) })
ctx.restore()
}
// ====== 6. 自动换行多行文本 ======
function drawWrappedTextDemo() {
ctx.save()
ctx.fillStyle = "#333"
ctx.font = "bold 14px sans-serif"
ctx.fillText("自动换行文本 (measureText 实现)", 500, 560)
function drawWrappedText(ctx, text, x, y, maxWidth, lineHeight) {
let line = ""
let currentY = y
for (let i = 0; i < text.length; i++) {
const testLine = line + text[i]
if (ctx.measureText(testLine).width > maxWidth && line !== "") {
ctx.fillText(line, x, currentY)
line = text[i]
currentY += lineHeight
} else {
line = testLine
}
}
if (line) ctx.fillText(line, x, currentY)
return currentY + lineHeight
}
// 边框
ctx.strokeStyle = "#ddd"
ctx.lineWidth = 1
ctx.strokeRect(500, 575, 310, 80)
ctx.font = "14px 'Microsoft YaHei', sans-serif"
ctx.fillStyle = "#444"
drawWrappedText(ctx,
"这是一段很长的文本,需要在指定宽度内自动换行显示。使用 measureText 逐字检测宽度实现。",
508, 595, 294, 22
)
ctx.restore()
}
// ====== 7. 高级文本属性 ======
function drawAdvancedTextProps() {
ctx.save()
// direction 属性
ctx.fillStyle = "#333"
ctx.font = "bold 14px sans-serif"
ctx.fillText("高级属性:direction / letterSpacing / wordSpacing", 30, 655)
// direction: rtl
ctx.direction = "rtl"
ctx.font = "16px serif"
ctx.fillStyle = "#8e44ad"
ctx.fillText("从右到左文本 (RTL)", 820, 685)
ctx.direction = "ltr"
// letterSpacing (如果支持)
ctx.font = "14px sans-serif"
ctx.fillStyle="#666"
if (ctx.letterSpacing !== undefined) {
ctx.letterSpacing = "3px"
ctx.fillText("letterSpacing: 3px (宽字距)", 30, 685)
ctx.letterSpacing = "normal"
} else {
ctx.fillText("letterSpacing: 浏览器不支持", 30, 685)
}
ctx.restore()
}
// 初始绘制
drawAll()
</script>
</body>
</html>
<h4>010-animation-loop.html</h4>
<!-- 来源:12-Canvas.md - 第12章 动画系统架构 -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>【10】Canvas 动画循环</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 20px; background: #1a1a2e; color: #fff; }
.demo-container { max-width: 900px; margin: 0 auto; background: #16213e; padding: 24px; border-radius: 12px; box-shadow: 0 4px 20px rgba(0,0,0,0.3); }
.demo-title { margin-bottom: 16px; font-size: 18px; color: #e94560; border-bottom: 2px solid #e94560; padding-bottom: 8px; }
canvas {
display: block; margin: 16px auto; border-radius: 12px;
background: linear-gradient(135deg, #0f3460 0%, #16213e 100%);
}
.controls { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; margin-bottom: 16px; }
.btn {
padding: 9px 18px; border: none; border-radius: 6px; cursor: pointer;
font-size: 13px; font-weight: 500; transition: all 0.3s;
background: #e94560; color: white;
}
.btn:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(233,69,96,0.4); }
.btn-secondary { background: #533483; }
.btn-secondary:hover { box-shadow: 0 4px 12px rgba(83,52,131,0.4); }
.btn-success { background: #00d9ff; color: #1a1a2e; }
.btn-success:hover { box-shadow: 0 4px 12px rgba(0,217,255,0.4); }
.stats-panel {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-top: 14px;
}
.stat-card {
background: rgba(255,255,255,0.05);
border-radius: 8px;
padding: 12px;
text-align: center;
border: 1px solid rgba(255,255,255,0.08);
}
.stat-label { font-size: 11px; color: #888; text-transform: uppercase; letter-spacing: 1px; }
.stat-value { font-size: 22px; font-weight: bold; color: #00d9ff; font-family: monospace; margin-top: 4px; }
.stat-value.warning { color: #f39c12; }
.stat-value.danger { color: #e94560; }
.timeline-bar {
height: 6px; background: rgba(255,255,255,0.1); border-radius: 3px;
margin-top: 14px; overflow: hidden;
}
.timeline-progress {
height: 100%; background: linear-gradient(90deg, #e94560, #f39c12, #00d9ff);
border-radius: 3px; transition: width 0.1s linear;
}
.state-badge {
display: inline-block; padding: 3px 10px; border-radius: 12px;
font-size: 12px; font-weight: bold; margin-left: 8px;
}
.state-running { background: #00d9ff33; color: #00d9ff; }
.state-paused { background: #f39c1233; color: #f39c12; }
</style>
</head>
<body>
<div class="demo-container">
<div class="demo-title">
示例:Canvas 动画循环架构
<span id="stateBadge" class="state-badge state-running">● RUNNING</span>
</div>
<div class="controls">
<button class="btn btn-success" onclick="togglePlay()" id="playBtn">⏸️ 暂停</button>
<button class="btn" onclick="changeSpeed(-0.5)">🐢 减速</button>
<button class="btn" onclick="changeSpeed(0.5)">🐇 加速</button>
<button class="btn" onclick="addBall()">➕ 添加小球</button>
<button class="btn btn-secondary" onclick="resetScene()">🔄 重置场景</button>
</div>
<canvas id="animCanvas" width="850" height="480"></canvas>
<div class="timeline-bar">
<div class="timeline-progress" id="timelineProgress" style="width: 0%"></div>
</div>
<div class="stats-panel">
<div class="stat-card">
<div class="stat-label">FPS (实时)</div>
<div class="stat-value" id="fpsValue">60</div>
</div>
<div class="stat-card">
<div class="stat-label">Delta Time</div>
<div class="stat-value" id="deltaValue">16.67<span style="font-size:12px">ms</span></div>
</div>
<div class="stat-card">
<div class="stat-label">总帧数</div>
<div class="stat-value" id="frameCount">0</div>
</div>
<div class="stat-card">
<div class="stat-label">时间倍率</div>
<div class="stat-value" id="speedValue">1.0x</div>
</div>
</div>
</div>
<script>
const canvas = document.getElementById("animCanvas")
const ctx = canvas.getContext("2d")
// ====== 动画状态 ======
let isRunning = true
let timeScale = 1.0
let animId = null
// ====== 时间追踪 ======
let lastTimestamp = 0
let totalElapsed = 0 // 累计经过时间(受 timeScale 影响)
let rawElapsed = 0 // 原始经过时间
let frameCounter = 0
let fps = 60
let fpsAccumulator = 0
let fpsFrameCount = 0
const FPS_UPDATE_INTERVAL = 500 // 每500ms更新一次FPS显示
// ====== 小球数据 ======
const balls = []
class Ball {
constructor(x, y) {
this.x = x ?? Math.random() * canvas.width
this.y = y ?? Math.random() * canvas.height * 0.6
this.radius = 8 + Math.random() * 18
this.vx = (Math.random() - 0.5) * 200
this.vy = (Math.random() - 0.5) * 200
this.hue = Math.random() * 360
this.trail = []
this.maxTrail = 12
}
update(dt) {
// 使用 deltaTime 实现帧率无关运动
this.x += this.vx * dt
this.y += this.vy * dt
// 边界碰撞
if (this.x - this.radius < 0) { this.x = this.radius; this.vx *= -0.9 }
if (this.x + this.radius > canvas.width) { this.x = canvas.width - this.radius; this.vx *= -0.9 }
if (this.y - this.radius < 0) { this.y = this.radius; this.vy *= -0.9 }
if (this.y + this.radius > canvas.height) { this.y = canvas.height - this.radius; this.vy *= -0.9 }
// 轨迹记录
this.trail.push({ x: this.x, y: this.y })
if (this.trail.length > this.maxTrail) this.trail.shift()
}
draw(ctx) {
// 绘制轨迹
for (let i = 0; i < this.trail.length; i++) {
const alpha = i / this.trail.length * 0.4
const r = this.radius * (i / this.trail.length) * 0.7
ctx.beginPath()
ctx.arc(this.trail[i].x, this.trail[i].y, r, 0, Math.PI * 2)
ctx.fillStyle = `hsla(${this.hue}, 80%, 60%, ${alpha})`
ctx.fill()
}
// 绘制球体(径向渐变模拟光照)
const grad = ctx.createRadialGradient(
this.x - this.radius * 0.3, this.y - this.radius * 0.3, this.radius * 0.1,
this.x, this.y, this.radius
)
grad.addColorStop(0, `hsla(${this.hue}, 90%, 75%, 1)`)
grad.addColorStop(0.7, `hsla(${this.hue}, 80%, 50%, 1)`)
grad.addColorStop(1, `hsla(${this.hue}, 70%, 30%, 1)`)
ctx.beginPath()
ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2)
ctx.fillStyle = grad
ctx.fill()
// 高光
ctx.beginPath()
ctx.arc(this.x - this.radius * 0.3, this.y - this.radius * 0.3, this.radius * 0.2, 0, Math.PI * 2)
ctx.fillStyle = "rgba(255,255,255,0.5)"
ctx.fill()
}
}
function addBall(x, y) {
balls.push(new Ball(x, y))
}
function resetScene() {
balls.length = 0
totalElapsed = 0
rawElapsed = 0
frameCounter = 0
for (let i = 0; i < 6; i++) addBall()
}
// ====== 动画循环核心 ======
function animate(timestamp) {
if (!isRunning) return
// 计算 delta time(毫秒转秒)
if (lastTimestamp === 0) lastTimestamp = timestamp
const rawDelta = timestamp - lastTimestamp
lastTimestamp = timestamp
// 应用时间倍率
const deltaSec = (rawDelta / 1000) * timeScale
totalElapsed += deltaSec
rawElapsed += rawDelta / 1000
frameCounter++
// ====== FPS 计算 ======
fpsAccumulator += rawDelta
fpsFrameCount++
if (fpsAccumulator >= FPS_UPDATE_INTERVAL) {
fps = Math.round(fpsFrameCount / (fpsAccumulator / 1000))
fpsAccumulator = 0
fpsFrameCount = 0
updateStatsDisplay(fps, rawDelta)
}
// ====== 渲染 ======
render(deltaSec)
// 更新进度条(循环周期 10 秒)
const progress = (totalElapsed % 10) / 10 * 100
document.getElementById("timelineProgress").style.width = progress + "%"
animId = requestAnimationFrame(animate)
}
function render(dt) {
// 半透明清屏产生拖尾
ctx.fillStyle = "rgba(15, 52, 96, 0.2)"
ctx.fillRect(0, 0, canvas.width, canvas.height)
// 更新并绘制所有球
for (const ball of balls) {
ball.update(dt)
ball.draw(ctx)
}
// 绘制中心信息
ctx.save()
ctx.font = "bold 14px monospace"
ctx.fillStyle = "rgba(255,255,255,0.15)"
ctx.textAlign = "center"
ctx.fillText(`requestAnimationFrame + deltaTime | 帧率无关动画`, canvas.width / 2, canvas.height - 16)
ctx.restore()
}
// ====== 控制函数 ======
function togglePlay() {
isRunning = !isRunning
const badge = document.getElementById("stateBadge")
const btn = document.getElementById("playBtn")
if (isRunning) {
badge.textContent = "● RUNNING"
badge.className = "state-badge state-running"
btn.textContent = "⏸️ 暂停"
lastTimestamp = 0 // 重置以避免巨大 delta
animate(performance.now())
} else {
badge.textContent = "⏸ PAUSED"
badge.className = "state-badge state-paused"
btn.textContent = "▶️ 继续"
if (animId) cancelAnimationFrame(animId)
}
}
function changeSpeed(delta) {
timeScale = Math.max(0.1, Math.min(3.0, timeScale + delta))
document.getElementById("speedValue").textContent = timeScale.toFixed(1) + "x"
}
function updateStatsDisplay(currentFps, rawDt) {
const fpsEl = document.getElementById("fpsValue")
fpsEl.textContent = currentFps
fpsEl.className = "stat-value" + (currentFps < 30 ? " danger" : currentFps < 50 ? " warning" : "")
document.getElementById("deltaValue").innerHTML =
rawDt.toFixed(1) + '<span style="font-size:12px">ms</span>'
document.getElementById("frameCount").textContent = frameCounter
document.getElementById("speedValue").textContent = timeScale.toFixed(1) + "x"
}
// 点击画布添加球
canvas.addEventListener("click", (e) => {
const rect = canvas.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
addBall(x, y)
})
// 初始化
resetScene()
animate(performance.now())
</script>
</body>
</html>
<h4>012-clip-paths.html</h4>
<!-- 来源:12-Canvas.md - 裁剪路径 clip() -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>【12】Canvas 裁剪路径</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 20px; background: #f8f9fa; color: #333; }
.demo-container { max-width: 900px; margin: 0 auto; background: white; padding: 24px; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.demo-title { margin-bottom: 16px; font-size: 18px; color: #555; border-bottom: 2px solid #007bff; padding-bottom: 8px; }
canvas { display: block; margin: 16px auto; border: 1px solid #ddd; border-radius: 8px; background: white; }
.controls {
display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; margin-bottom: 16px;
}
.btn {
padding: 8px 16px; border: none; border-radius: 6px; cursor: pointer;
font-size: 13px; font-weight: 500; transition: all 0.3s;
background: #007bff; color: white;
}
.btn:hover { background: #0056b3; }
.btn-secondary { background: #6c757d; color: white; }
.clip-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px;
margin-top: 14px;
}
.clip-item h3 {
font-size: 13px; color: #555; text-align: center; margin-bottom: 10px;
}
</style>
</head>
<body>
<div class="demo-container">
<div class="demo-title">示例:Canvas 裁剪路径(clip() + save/restore)</div>
<div class="controls">
<button class="btn" onclick="drawAll()">🎨 绘制全部示例</button>
<button class="btn" onclick="toggleAnimation()">⏯️ 动画开/关</button>
<button class="btn btn-secondary" onclick="clearCanvas()">🗑️ 清空</button>
</div>
<canvas id="clipCanvas" width="850" height="600"></canvas>
<p style="text-align:center;margin-top:12px;font-size:13px;color:#666;">
💡 clip() 将当前路径设为裁剪区域,之后所有绘制只在该区域内可见 | 必须配合 save()/restore() 使用
</p>
</div>
<script>
const canvas = document.getElementById("clipCanvas")
const ctx = canvas.getContext("2d")
let animating = true
let animId = null
let animAngle = 0
function clearCanvas() {
ctx.clearRect(0, 0, canvas.width, canvas.height)
}
function drawAll() {
clearCanvas()
drawRectClip()
drawCircleClip()
drawStarClip()
drawTextClip()
drawNestedClips()
drawAnimatedClip()
}
// ====== 1. 矩形裁剪 ======
function drawRectClip() {
ctx.save()
// 绘制裁剪区域边框(虚线,用于可视化)
ctx.strokeStyle = "#e74c3c"
ctx.lineWidth = 2
ctx.setLineDash([5, 3])
ctx.strokeRect(30, 30, 200, 140)
ctx.setLineDash([])
// 定义裁剪路径并应用
ctx.beginPath()
ctx.rect(30, 30, 200, 140)
ctx.clip()
// 在裁剪区域内绘制内容(超出部分不可见)
const grad = ctx.createLinearGradient(30, 30, 230, 170)
grad.addColorStop(0, "#667eea")
grad.addColorStop(1, "#f093fb")
ctx.fillStyle = grad
ctx.fillRect(0, 0, 300, 220) // 故意画大,展示裁剪效果
// 绘制一些圆形
for (let i = 0; i < 12; i++) {
ctx.beginPath()
ctx.arc(50 + i * 22, 60 + Math.sin(i) * 40, 15, 0, Math.PI * 2)
ctx.fillStyle = `hsla(${i*30},70%,60%,0.6)`
ctx.fill()
}
// 标签
ctx.restore()
ctx.font = "bold 13px sans-serif"
ctx.fillStyle = "#333"
ctx.fillText("① rect() + clip() — 矩形裁剪", 35, 190)
ctx.font = "11px monospace"
ctx.fillStyle="#888"
ctx.fillText('ctx.rect(x,y,w,h); ctx.clip()', 35, 206)
}
// ====== 2. 圆形裁剪 ======
function drawCircleClip() {
ctx.save()
// 可视化裁剪圆
ctx.strokeStyle = "#3498db"
ctx.lineWidth = 2
ctx.setLineDash([5, 3])
ctx.beginPath()
ctx.arc(420, 100, 80, 0, Math.PI * 2)
ctx.stroke()
ctx.setLineDash([])
// 圆形裁剪
ctx.beginPath()
ctx.arc(420, 100, 80, 0, Math.PI * 2)
ctx.clip()
// 填充渐变图案
const grad = ctx.createRadialGradient(420, 100, 10, 420, 100, 90)
grad.addColorStop(0, "#f39c12")
grad.addColorStop(0.5, "#e74c3c")
grad.addColorStop(1, "#9b59b6")
ctx.fillStyle = grad
ctx.fillRect(300, 0, 240, 220)
// 内部文字
ctx.fillStyle = "white"
ctx.font = "bold 18px sans-serif"
ctx.textAlign = "center"
ctx.textBaseline = "middle"
ctx.fillText("Circle Clip", 420, 95)
ctx.font = "12px sans-serif"
ctx.fillText("圆形裁剪区域", 420, 118)
ctx.restore()
ctx.textAlign = "left"
ctx.textBaseline = "alphabetic"
ctx.font = "bold 13px sans-serif"
ctx.fillStyle = "#333"
ctx.fillText("② arc() + clip() — 圆形裁剪", 330, 210)
}
// ====== 3. 星形裁剪 ======
function drawStarClip() {
ctx.save()
const cx = 650, cy = 100, outerR = 75, innerR = 35
// 可视化星形路径
ctx.strokeStyle = "#2ecc71"
ctx.lineWidth = 2
ctx.setLineDash([5, 3])
drawStarPath(ctx, cx, cy, outerR, innerR, 5)
ctx.stroke()
ctx.setLineDash([])
// 星形裁剪
ctx.beginPath()
drawStarPath(ctx, cx, cy, outerR, innerR, 5)
ctx.clip()
// 在星形内绘制彩虹条纹
for (let i = 0; i < 20; i++) {
ctx.fillStyle = `hsl(${i * 18}, 70%, 55%)`
ctx.fillRect(cx - 100 + i * 12, cy - 100, 10, 200)
}
ctx.restore()
ctx.font = "bold 13px sans-serif"
ctx.fillStyle = "#333"
ctx.fillText("③ 自定义路径 + clip() — 星形裁剪", 555, 210)
}
// ====== 4. 文字形状遮罩 ======
function drawTextClip() {
ctx.save()
const txt = "CLIP"
ctx.font = "bold 72px 'Impact', sans-serif"
// 测量文字尺寸
const metrics = ctx.measureText(txt)
const textWidth = metrics.width
const tx = 130, ty = 310
// 说明:clip() 只能使用路径,文字轮廓无法直接作为裁剪路径,
// 这里用 globalCompositeOperation = "destination-in" 模拟文字遮罩
const bgGrad = ctx.createLinearGradient(tx, ty - 70, tx + textWidth, ty + 10)
bgGrad.addColorStop(0, "#e74c3c")
bgGrad.addColorStop(0.33, "#f39c12")
bgGrad.addColorStop(0.66, "#2ecc71")
bgGrad.addColorStop(1, "#3498db")
// 先画彩色背景
ctx.fillStyle = bgGrad
ctx.fillRect(tx - 10, ty - 78, textWidth + 20, 88)
// 文字遮罩(destination-in 只保留与文字重叠的部分)
ctx.globalCompositeOperation = "destination-in"
ctx.fillStyle = "#fff"
ctx.font = "bold 72px 'Impact', sans-serif"
ctx.fillText(txt, tx, ty)
ctx.globalCompositeOperation = "source-over"
ctx.restore()
ctx.font = "bold 13px sans-serif"
ctx.fillStyle = "#333"
ctx.fillText("④ 文字形状遮罩(合成模式模拟)", 40, 360)
ctx.font = "11px monospace"
ctx.fillStyle="#888"
ctx.fillText("destination-in 实现文字镂空效果", 40, 376)
}
// ====== 5. 嵌套裁剪(save/restore 栈)======
function drawNestedClips() {
ctx.save()
// 外层:矩形裁剪
ctx.beginPath()
ctx.roundRect(400, 260, 200, 150, 12)
ctx.clip()
// 外层内容
ctx.fillStyle = "#f0f3ff"
ctx.fillRect(380, 240, 240, 190)
ctx.save() // 保存外层裁剪状态
// 内层:圆形裁剪
ctx.beginPath()
ctx.arc(500, 335, 55, 0, Math.PI * 2)
ctx.clip()
// 内层内容(只在内层圆形内可见)
const grad = ctx.createRadialGradient(500, 335, 5, 500, 335, 60)
grad.addColorStop(0, "#ff6b6b")
grad.addColorStop(1, "#4ecdc4")
ctx.fillStyle = grad
ctx.fillRect(400, 250, 200, 170)
ctx.fillStyle = "white"
ctx.font = "bold 14px sans-serif"
ctx.textAlign = "center"
ctx.textBaseline = "middle"
ctx.fillText("嵌套裁剪", 500, 335)
ctx.restore() // 恢复到外层裁剪
// 在外层(但不在内层)绘制边框装饰
ctx.strokeStyle = "#3498db"
ctx.lineWidth = 2
ctx.setLineDash([4, 4])
ctx.roundRect(400, 260, 200, 150, 12)
ctx.stroke()
ctx.setLineDash([])
ctx.restore()
ctx.font = "bold 13px sans-serif"
ctx.fillStyle = "#333"
ctx.textAlign = "left"
ctx.textBaseline = "alphabetic"
ctx.fillText("⑤ 嵌套裁剪 — save/restore 栈式管理", 400, 430)
ctx.font = "11px monospace"
ctx.fillStyle="#888"
ctx.fillText("外层clip → save → 内层clip → restore → 回到外层", 400, 446)
}
// ====== 6. 动态裁剪动画 ======
function drawAnimatedClip() {
ctx.save()
const cx = 750, cy = 335
const wobble = Math.sin(animAngle) * 15
const r = 65 + Math.sin(animAngle * 1.5) * 10
// 动态星形裁剪
// 注意:变换只影响之后添加的路径点,需先应用变换再定义路径
ctx.translate(cx, cy)
ctx.rotate(animAngle * 0.5)
ctx.translate(-cx, -cy)
ctx.beginPath()
drawStarPath(ctx, cx, cy, r, r * 0.45, 5)
ctx.clip()
// 绘制动态渐变内容
const time = Date.now() / 1000
for (let i = 0; i < 15; i++) {
const hue = (i * 24 + time * 30) % 360
ctx.fillStyle = `hsl(${hue}, 70%, 55%)`
ctx.beginPath()
ctx.arc(
cx + Math.sin(time + i) * 50,
cy + Math.cos(time * 0.7 + i) * 50,
12 + Math.sin(time * 2 + i) * 5,
0, Math.PI * 2
)
ctx.fill()
}
ctx.restore()
ctx.font = "bold 13px sans-serif"
ctx.fillStyle = "#333"
ctx.fillText("⑥ 动态裁剪 — 路径随时间变化", 660, 438)
}
function drawStarPath(ctx, cx, cy, outerR, innerR, points) {
const step = Math.PI / points
ctx.moveTo(cx, cy - outerR)
for (let i = 1; i <= points * 2; i++) {
const r = i % 2 === 0 ? outerR : innerR
const angle = i * step - Math.PI / 2
ctx.lineTo(cx + r * Math.cos(angle), cy + r * Math.sin(angle))
}
ctx.closePath()
}
function toggleAnimation() {
animating = !animating
if (animating && !animId) loop()
}
function loop() {
if (!animating) { animId = null; return }
animAngle += 0.03
drawAll()
animId = requestAnimationFrame(loop)
}
// 初始化
ctx.fillStyle = "#fff"
ctx.fillRect(0, 0, canvas.width, canvas.height)
loop()
</script>
</body>
</html>
<h4>013-offscreen-rendering.html</h4>
<!-- 来源:12-Canvas.md - 第13章 OffscreenCanvas 与多线程渲染 / 第15章 分层渲染 -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>【13】Canvas 离屏渲染</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 20px; background: #f8f9fa; color: #333; }
.demo-container { max-width: 900px; margin: 0 auto; background: white; padding: 24px; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.demo-title { margin-bottom: 16px; font-size: 18px; color: #555; border-bottom: 2px solid #007bff; padding-bottom: 8px; }
canvas { display: block; margin: 16px auto; border: 1px solid #ddd; border-radius: 8px; background: white; }
.controls {
display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; margin-bottom: 16px;
}
.btn {
padding: 8px 16px; border: none; border-radius: 6px; cursor: pointer;
font-size: 13px; font-weight: 500; transition: all 0.3s;
background: #007bff; color: white;
}
.btn:hover { background: #0056b3; }
.btn-secondary { background: #6c757d; color: white; }
.comparison {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-top: 14px;
}
.comp-panel h3 {
text-align: center; font-size: 14px; color: #555; margin-bottom: 10px;
padding-bottom: 6px; border-bottom: 2px solid #eee;
}
.stats-row {
display: flex; gap: 20px; justify-content: center; margin-top: 14px;
}
.stat-box {
background: #f8f9fa; padding: 10px 18px; border-radius: 6px;
text-align: center; font-size: 13px;
}
.stat-box strong { color: #007bff; font-size: 16px; }
.code-block {
background: #1e1e2e; color: #cdd6f4; padding: 12px 14px;
border-radius: 6px; font-family: "SF Mono", "Fira Code", monospace;
font-size: 12px; line-height: 1.6; margin-top: 12px;
overflow-x: auto; white-space: pre;
}
.code-comment { color: #6c7086; }
.code-keyword { color: #cba6f7; }
.code-string { color: #a6e3a1; }
.code-func { color: #89b4fa; }
</style>
</head>
<body>
<div class="demo-container">
<div class="demo-title">示例:Canvas 离屏渲染(Offscreen / 预渲染缓存)</div>
<div class="controls">
<button class="btn" onclick="toggleAnimation()">⏯️ 动画开/关</button>
<button class="btn" onclick="addParticles()">✨ 添加粒子</button>
<button class="btn btn-secondary" onclick="resetScene()">🔄 重置</button>
</div>
<canvas id="mainCanvas" width="850" height="480"></canvas>
<div class="stats-row">
<div class="stat-box">离屏层数:<strong id="layerCount">3</strong></div>
<div class="stat-box">FPS:<strong id="fpsDisplay">60</strong></div>
<div class="stat-box">粒子数:<strong id="particleCount">0</strong></div>
</div>
<div class="comparison" style="margin-top:18px;">
<div class="comp-panel">
<h3>📐 原理说明</h3>
<div class="code-block"><span class="code-comment">// 创建离屏 Canvas(不显示在页面上)</span>
<span class="code-keyword">const</span> offscreen = document.<span class="code-func">createElement</span>(<span class="code-string">'canvas'</span>);
offscreen.width = <span class="code-string">400</span>;
offscreen.height = <span class="code-string">300</span>;
<span class="code-keyword">const</span> offCtx = offscreen.<span class="code-func">getContext</span>(<span class="code-string">'2d'</span>);
<span class="code-comment">// 在离屏 Canvas 上预渲染复杂内容</span>
offCtx.fillStyle = <span class="code-string">'#gradient'</span>;
offCtx.fillRect(<span class="code-string">0, 0, 400, 300</span>);
<span class="code-comment">// 将离屏内容一次性绘制到主 Canvas</span>
ctx.<span class="code-func">drawImage</span>(offscreen, x, y);</div>
</div>
<div class="comp-panel">
<h3>💡 使用场景</h3>
<div class="code-block"><span class="code-comment">✅ 静态背景预渲染 — 只需绘制一次
✅ 复杂图形缓存 — 避免每帧重算
✅ 图层分层系统 — 独立更新各层
✅ 图像预处理 — 滤镜/变形离屏完成
✅ 双缓冲技术 — 减少画面闪烁
❌ 注意:离屏 Canvas 占用内存
不需要时及时清理 (width = 0)</span></div>
</div>
</div>
</div>
<script>
const canvas = document.getElementById("mainCanvas")
const ctx = canvas.getContext("2d")
// ====== 离屏图层系统 ======
const layers = {}
function createLayer(name, w, h) {
const offscreen = document.createElement("canvas")
offscreen.width = w
offscreen.height = h
const octx = offscreen.getContext("2d")
layers[name] = { canvas: offscreen, ctx: octx, dirty: true }
return layers[name]
}
// 初始化三个离屏层
createLayer("background", canvas.width, canvas.height)
createLayer("midground", canvas.width, canvas.height)
createLayer("particles", canvas.width, canvas.height)
// ====== 绘制各层内容 ======
// 背景层(静态,只绘制一次)
function renderBackground() {
const layer = layers.background
const c = layer.ctx
const w = layer.canvas.width
const h = layer.canvas.height
// 渐变背景
const bgGrad = c.createLinearGradient(0, 0, w, h)
bgGrad.addColorStop(0, "#0f0c29")
bgGrad.addColorStop(0.5, "#302b63")
bgGrad.addColorStop(1, "#24243e")
c.fillStyle = bgGrad
c.fillRect(0, 0, w, h)
// 网格线
c.strokeStyle = "rgba(255,255,255,0.04)"
c.lineWidth = 1
for (let x = 0; x < w; x += 40) { c.beginPath(); c.moveTo(x, 0); c.lineTo(x, h); c.stroke() }
for (let y = 0; y < h; y += 40) { c.beginPath(); c.moveTo(0, y); c.lineTo(w, y); c.stroke() }
// 装饰性几何图形
for (let i = 0; i < 8; i++) {
c.beginPath()
c.arc(
80 + i * 110,
80 + Math.sin(i * 1.2) * 60,
30 + Math.random() * 50,
0, Math.PI * 2
)
c.fillStyle = `hsla(${i * 45}, 60%, 50%, 0.08)`
c.fill()
}
// 标签
c.fillStyle = "rgba(255,255,255,0.15)"
c.font = "bold 24px sans-serif"
c.textAlign = "center"
c.fillText("【离屏背景层】预渲染静态内容", w / 2, h - 30)
c.textAlign = "left"
layer.dirty = false
}
// 中间层(半动态)
let midAngle = 0
function renderMidground() {
const layer = layers.midground
const c = layer.ctx
const w = layer.canvas.width
const h = layer.canvas.height
c.clearRect(0, 0, w, h)
// 旋转的装饰环
const cx = w / 2, cy = h / 2
for (let ring = 0; ring < 3; ring++) {
const radius = 100 + ring * 60
c.save()
c.translate(cx, cy)
c.rotate(midAngle * (ring % 2 === 0 ? 1 : -0.7))
for (let i = 0; i < 6; i++) {
const angle = (Math.PI * 2 / 6) * i + midAngle * 0.5
const x = Math.cos(angle) * radius
const y = Math.sin(angle) * radius
const size = 12 + ring * 5
c.beginPath()
c.arc(x, y, size, 0, Math.PI * 2)
const grad = c.createRadialGradient(x, y, 0, x, y, size)
grad.addColorStop(0, `hsla(${ring * 120 + i * 30}, 80%, 65%, 0.9)`)
grad.addColorStop(1, `hsla(${ring * 120 + i * 30}, 70%, 45%, 0.3)`)
c.fillStyle = grad
c.fill()
}
c.restore()
}
// 中心文字
c.save()
c.translate(cx, cy)
c.rotate(midAngle * 0.2)
c.fillStyle = "rgba(255,255,255,0.25)"
c.font = "bold 28px sans-serif"
c.textAlign = "center"
c.textBaseline = "middle"
c.fillText("OFFSCREEN", 0, -10)
c.font = "16px sans-serif"
c.fillText("RENDERING", 0, 18)
c.restore()
}
// 粒子层
const particles = []
class Particle {
constructor(x, y) {
this.x = x ?? Math.random() * canvas.width
this.y = y ?? Math.random() * canvas.height
this.vx = (Math.random() - 0.5) * 3
this.vy = (Math.random() - 0.5) * 3
this.radius = 2 + Math.random() * 5
this.hue = Math.random() * 360
this.alpha = 1
this.decay = 0.002 + Math.random() * 0.005
}
update() {
this.x += this.vx
this.y += this.vy
if (this.x < 0 || this.x > canvas.width) this.vx *= -1
if (this.y < 0 || this.y > canvas.height) this.vy *= -1
this.alpha -= this.decay
}
draw(c) {
c.beginPath()
c.arc(this.x, this.y, this.radius, 0, Math.PI * 2)
c.fillStyle = `hsla(${this.hue}, 80%, 60%, ${this.alpha})`
c.fill()
}
}
function addParticles() {
for (let i = 0; i < 30; i++) particles.push(new Particle())
}
function renderParticles() {
const layer = layers.particles
const c = layer.ctx
c.clearRect(0, 0, layer.canvas.width, layer.canvas.height)
for (let i = particles.length - 1; i >= 0; i--) {
particles[i].update()
particles[i].draw(c)
if (particles[i].alpha <= 0) particles.splice(i, 1)
}
}
// ====== 合成到主画布 ======
function composite() {
ctx.clearRect(0, 0, canvas.width, canvas.height)
// 按顺序合成各层
ctx.drawImage(layers.background.canvas, 0, 0)
ctx.drawImage(layers.midground.canvas, 0, 0)
ctx.drawImage(layers.particles.canvas, 0, 0)
// UI 层(直接在主 canvas 上绘制)
ctx.fillStyle = "rgba(255,255,255,0.08)"
ctx.fillRect(0, 0, canvas.width, 28)
ctx.fillStyle = "rgba(255,255,255,0.5)"
ctx.font = "11px monospace"
ctx.fillText(`Layers: background → midground → particles → UI (main canvas)`, 10, 18)
}
// ====== 动画循环 ======
let isRunning = true
let animId = null
let fps = 60, frameCount = 0, lastFpsTime = performance.now()
function animate(time) {
if (!isRunning) return
// FPS 计算
frameCount++
if (time - lastFpsTime >= 1000) {
fps = frameCount
frameCount = 0
lastFpsTime = time
document.getElementById("fpsDisplay").textContent = fps
}
// 更新中间层动画
midAngle += 0.01
// 渲染各层
if (layers.background.dirty) renderBackground()
renderMidground()
renderParticles()
// 合成
composite()
// 更新统计
document.getElementById("particleCount").textContent = particles.length
animId = requestAnimationFrame(animate)
}
function toggleAnimation() {
isRunning = !isRunning
if (isRunning && !animId) animate(performance.now())
else if (!isRunning && animId) { cancelAnimationFrame(animId); animId = null }
}
function resetScene() {
particles.length = 0
midAngle = 0
layers.background.dirty = true
for (let i = 0; i < 15; i++) particles.push(new Particle())
}
// 点击添加粒子
canvas.addEventListener("click", (e) => {
const rect = canvas.getBoundingClientRect()
for (let i = 0; i < 10; i++) {
particles.push(new Particle(e.clientX - rect.left, e.clientY - rect.top))
}
})
// 初始化
renderBackground()
resetScene()
animate(performance.now())
</script>
</body>
</html>