图形开发学院|GraphAnyWhere
- 课程名称:图形系统开发实战课程(基础篇)
- 课程章节:“动画”
- 原文地址:https://graphanywhere.com/graph/foundation/1-8.html
\quad 图形系统的动画是指在计算机图形系统中通过一系列连续的画面来展示物体或场景的运动和变化。这些画面通常被称为帧,它们以一定的帧率(即每秒显示的帧数)进行播放,从而在视觉上呈现出连续的运动效果。
\quad 在Canvas中绘制的图形,在绘制之后以位图的形式融合到了Canvas中,如果需要让图形内容动起来,就需要清除已有内容,重新绘制新的内容,这样不停的擦除和重新绘制,就可以实现动画效果。在1秒内如果能够重复绘制15帧以上,图形看起来就不卡了,通常所说的60帧是指每秒重绘60次,这已经超过了人眼的极限,可以达到非常流畅的效果。
\quad 为了实现动画,我们需要一些可以定时执行重绘的方法。在Canvas中有三种方法可以实现:分别为setInterval()
、 setTimeout()
和 requestAnimationFrame()
,这3个方法都是html中全局window对象提供的。
名称 | 说明 |
---|---|
setInterval(function, delay) | 按时间间隔定期执行function |
setTimeout(function, delay) | 在设定好的时间之后执行函数 |
requestAnimationFrame(function) | 请求浏览器执行一个特定的函数来更新动画 |
setInterval(function, delay)
\quad 该函数将一直重复定期地执行某函数,直到执行clearInterval()
,不管当前网页是否可见,该函数都将定期重复执行。通常如下编写动画代码:
let timeId;
let done = true;
function frame() {
console.info("redraw");
if(done === true) {
draw();
} else {
clearInterval(timeId);
}
}
timeId = setInterval(frame, 1000/60); // 每1000毫秒(每秒)执行60次
setTimeout(function, delay)
\quad 执行该函数将建立一个定时器,一旦定时器到期,就会执行指定的函数,该函数只会执行一次,如果需要再次执行,可以在该函数中调用setTimeout()
。如果时间还未到,则可通过clearTimeout()
取消该定时器。通常如下编写动画代码:
let timeId;
let done = false;
function frame() {
console.info("redraw");
if(done === true) {
draw();
timeId = setTimeout(frame, 20);
}
}
timeId = setTimeout(frame, 20); // 20毫秒之后执行loop函数
requestAnimationFrame(function)
\quad 该函数告诉浏览器执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。该回调函数会在浏览器下一次重绘之前执行一次,如果需要继续执行,则需在回调函数中再次调用 requestAnimationFrame()。
\quad 浏览器在下一次重绘指的是显示器下一次刷新的时候,显示器刷新频率通常是60Hz,即每秒刷新60次。为了提高性能和电池寿命,在大多数浏览器里,当 requestAnimationFrame() 运行在后台标签页或者隐藏的
\quad 与setInterval()
和setTimeout()
类似,浏览器还提供了cancelAnimationFrame()
方法,以取消回调函数请求。通常如下编写动画代码:
let done = false;
function frame() {
console.info("redraw");
if(done === true) {
draw();
requestAnimationFrame(frame);
}
}
requestAnimationFrame(frame);
\quad 虽然动画是一种持续的循环,但我们不能纯粹使用javaScript循环绘制图形的方式来实现动画,这将导致浏览器失去响应。下面这段实际上是个死循环,由于浏览器是在主线程中执行JavaScript的,这种死循环将会使浏览器无法响应用户输入。
let done = false;
function frame() {
console.info("redraw");
draw();
}
white (true) {
if(done === true) {
frame();
}
}
setInterval(function, delay)
和setTimeout(function, delay)
有很多用途,也可以实现动画,但它们的时间控制不够精确,而且可能会因为各种因素导致实际执行时间与预期不符,从而产生意外的动画效果,因此建议尽量使用浏览器提供的方案requestAnimationFrame(function)
来实现动画。
在图形系统中,通常按以下几个步骤实现动画:
下面这个示例实现了一个简单的进度栏,其运行效果如下:
\quad 这个示例首先绘制了一个半透明的圆作为背景,在背景之上第一次绘制了一个相同圆心和相同半径的1°的圆弧。动画的下一帧是清除画布后,绘制一个半透明背景圆作和一个相同圆心相同半径的2°的圆弧,下一次执行时圆弧弧度继续增加1°,当达到360°时停止动画。源代码如下:
<script>
// 从页面中获取画板对象
let canvas = document.getElementById('canvas'),
ctx = canvas.getContext('2d');
let angle = 0,
x = 200,
y = 200,
radius = 160;
// 绘制进度栏
function drawProcess() {
ctx.save();
// 绘制半透明圆
ctx.beginPath();
ctx.lineWidth = 12;
ctx.strokeStyle = "#FF000022";
ctx.arc(x, y, radius, 0, 2 * Math.PI);
ctx.stroke();
// 绘制圆弧
ctx.beginPath();
ctx.strokeStyle = "red";
ctx.arc(x, y, radius, 0, angle * Math.PI / 180);
ctx.stroke();
// 绘制百分比文字
ctx.font = "40px 黑体";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(Math.floor(angle / 3.6) + "%", x, y);
ctx.restore();
}
/**
* 绘制帧
*/
function frame() {
// 步骤1:清空画布
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 绘制网格线
drawGrid('lightgray', 10, 10, ctx);
// 步骤2:绘制进度栏
drawProcess();
// 判断是否继续动画
if (angle < 360) {
// 步骤3:计算下一帧图形数据
angle += 1;
// 步骤4
animationFrame = window.requestAnimationFrame(frame);
}
}
// 开始动画
window.requestAnimationFrame(frame);
</script>
\quad 上面这个动画的示例是通过window.requestAnimationFrame(frame)
实现连续动画的,由于requestAnimationFrame()
是根据屏幕刷新频率来确定下一次执行时间的,有的屏幕刷新率比较低,有的刷新率比较高,这就造成了一个现象,在不同的电脑上运行上面这个示例,其运行速度是不一样的。而我们希望实现的效果是不论屏幕刷新率如何,动画都应该以稳定的速度执行才对。
\quad 动画是由一系列叫做“帧(frame)”的图形组成的,这些图形的显示频率就叫做“帧率”,即每秒钟显示的图像数量(fps)。
\quad 在帧绘制之前我们记录当前的执行时间(精准到毫秒),在下一次帧绘制的时候我们再次记录一个当前执行时间,用1000除以这两个时间的差值,就可以计算出帧率。
\quad 下面的代码定义一个变量lastTime
,增加了一个帧率计算函数calculateFps()
,并在上述代码中的frame()
函数增加对该函数的调用,即可实现帧率的计算与显示。
let lastTime = Date.now();
// 计算帧率
function calculateFps() {
let fps = 1000 / (Date.now() - lastTime);
lastTime = Date.now();
return Math.round(fps);
}
运行效果如下:
\quad 在上面这个进度栏动画中,进度栏的变化是根据帧率变化的,每一帧其圆弧的旋转角度都会增加1度,这将导致帧率高的计算机跑完整个动画的时间更短。下面我将增加“按时间控制进度栏”的功能,让所有机器的执行时间都一致。
\quad 主要的改动是在旋转角度的计算规则方面,之前的计算规则每执行一次代表进度的圆弧增加1度,我们修订为根据当前时间与开始执行时间的差值进行计算。修改后的frame()
函数如下:
/**
* 绘制帧
*/
function frame() {
// 计算并显示帧率
document.getElementById("txtFPS").innerHTML = calculateFps() + "fps";
document.getElementById("txtExecuteTime").innerHTML = Math.round((Date.now() - beginTime) / 1000) + "秒";
// 清空画布
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 绘制网格线
drawGrid('lightgray', 10, 10, ctx);
// 绘制进度栏
drawProcess();
// 判断是否继续动画
if (angle < 360) {
// 是否按照时间进度控制进度栏
if (chkTime.checked === true) {
angle = ((Date.now() - beginTime) / (totalRunTime * 1000)) * 360;
} else {
angle += 1;
}
animationFrame = window.requestAnimationFrame(frame);
}
}
界面中增加了一个复选框,选中后可按照时间进度控制进度栏的速度,采用该选项后需30秒执行整个过程,修改后的执行效果图如下所示:
作为进度栏,更加准确的计算进度的方式是根据业务逻辑来确定当前进展比例,这里只是将其作为解释动画的实现过程。
\quad 下面这个时钟的示例应用了动画的实现、矩阵变换中旋转的技巧,还应用了渐变的渲染效果。界面外观如下所示:
\quad 时钟的外观非常简单,通常就是一个圆圈,在这里我们给这个圆圈添加一点渐变效果,使其更加逼真,关于渐变的具体实现方法请参见渲染效果中的介绍。界面如下所示:
时钟外观的源代码如下:
/**
* 绘制时钟外观
*/
function drawFacade() {
// 外圈渐变样式
const gradient = ctx.createRadialGradient(x, y, 0, x, y, radius+25);
gradient.addColorStop(0, "rgb(255,255,255)");
gradient.addColorStop(0.817, "rgb(255,255,255)");
gradient.addColorStop(0.837, "rgb(237,237,237)");
gradient.addColorStop(0.874, "rgb(77,77,77)");
gradient.addColorStop(0.904, "rgb(236,236,236)");
gradient.addColorStop(0.969, "rgba(77,77,77,168)");
gradient.addColorStop(1, "rgba(77,77,77,0)");
// 绘制外圈
ctx.save();
ctx.beginPath();
ctx.arc(x, y, radius+10, 0, 2 * Math.PI);
ctx.fillStyle = gradient;
ctx.fill();
// 绘制中心
ctx.beginPath();
ctx.fillStyle = "gray";
ctx.arc(x, y, 10, 0, 2 * Math.PI)
ctx.fill();
ctx.restore();
}
\quad 通常的时钟每分钟都会绘制一个刻度,运用平移和旋转技术可轻松实现时间刻度的绘制,关于旋转的具体实现方法请参见变形操作中的介绍。时间刻度的界面如下所示:
\quad 在时钟中需绘制每分钟的刻度,可通过循环逐分钟的绘制。数字时钟文字的实现时稍微复杂一点,需先按时钟中心旋转画布,还需按文字中心的反向角度进行旋转,否则绘制的文字将会出现旋转的效果。
\quad 旋转角度的在画布中是以渲染上下文对象的X坐标轴向右的方向为起始边,按照顺时针旋转作为夹角为另一条边计算的。例如:时钟中的3点方向是0°,6点方向是90°,9点方向是180°,12点方向是270°。需注意在绘制文字的时候应减去45°,否则会把3点绘制在6点的位置。文字表达比较拗口,原代码更加直观,如下:
// 绘制时钟刻度
let min = 60;
for (let i = 0; i < min; i++) {
drawScale(x, y, radius, i);
}
/**
* 绘制时钟刻度函数
*/
function drawScale(x, y, radius, min) {
let deg = min * 6; // 每分钟为6°
let len = 20;
ctx.save();
// 按时钟中心旋转
ctx.translate(x, y);
ctx.rotate(toRadians(deg));
ctx.beginPath();
// 5的整数分钟的刻度要长一些粗一些
if (min % 5 == 0) {
ctx.lineWidth = 4;
ctx.moveTo(radius - 30, 0);
} else {
ctx.lineWidth = 2;
ctx.moveTo(radius - 20, 0);
}
ctx.lineTo(radius - 8, 0);
ctx.stroke();
ctx.restore();
// 每5分钟显示数字值
if (min % 5 == 0) {
ctx.save();
// 按按时钟中心旋转
ctx.translate(x, y);
ctx.rotate(toRadians(deg));
// 平移至文字所在位置,并反向旋转
ctx.translate(radius - 45, 0);
ctx.rotate(toRadians(-deg));
ctx.fillStyle = "red";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.font = "bold 25px 黑体";
//
ctx.fillText((min > 45 ? (min - 45) : (min + 15)) / 5, 0, 0);
ctx.restore()
}
}
\quad 绘制时针、分针和秒针时,需注意各类时针的长度和粗细,其位置可采用画布旋转来实现。
\quad 在计算时针和分针的角度时,除了考虑时针和分针自身的角度,还需考虑分和秒对角度的影响,以及0点在画布中的角度因素。因此:
house * 30° + minute/60 * 30° - 90°
minute * 6° + sec/60 * 6° - 90°
// 绘制时针、分针、秒针
drawPointer("h");
drawPointer("m");
drawPointer("s");
function drawPointer(type, value) {
let deg, beginX, endX;
let now = new Date();
ctx.save();
// 计算时针角度和长度,并设置样式
if (type == "s") { // 秒
deg = now.getSeconds() * 6 - 90;
beginX = -30;
endX = radius - 50; // 长
ctx.lineWidth = 4; // 细
ctx.strokeStyle = "gold";
} else if (type == "m") { // 分
deg = now.getMinutes() * 6 + ((now.getSeconds() / 60) * 6) - 90;
beginX = -20;
endX = radius - 70;
ctx.lineWidth = 6;
ctx.strokeStyle = "#2400CC";
} else { // 时
deg = now.getHours() * 30 + ((now.getMinutes() / 60) * 30) - 90;
beginX = -15;
endX = radius - 90;
ctx.lineWidth = 8;
ctx.strokeStyle = "#0018CC";
}
// 绘制
ctx.translate(x, y);
ctx.rotate(toRadians(deg));
ctx.beginPath();
ctx.moveTo(beginX, 0);
ctx.lineTo(endX, 0);
ctx.stroke();
ctx.restore();
}
\quad 时钟中的秒针每秒都会走动,这就需要采用动画技术来实现。由于时钟画面每秒仅需重绘一次,因此采用setInterval()
、 setTimeout()
和 requestAnimationFrame()
这三个中的任意一个均可实现动画。其运行效果如下图所示:
\quad 在本示例中依旧采用requestAnimationFrame()
实现动画,而requestAnimationFrame()
是在屏幕刷新时执行的,通常的屏幕每秒会刷新60次,由于时钟每秒仅需重绘一次,因此在本示例中增加了一个变量lastSecond
,用于记录上一次绘制时钟时的秒数,而每当当前秒数大于上一次绘制秒数的时候重绘一次时钟,从而既实现了时钟的走动功能,又减轻了计算机的运行负荷。其源代码如下:
<script>
// 从页面中获取画板对象
let canvas = document.getElementById('canvas');
let ctx = canvas.getContext('2d');
// 时钟大小
let x = 250, y = 225, radius = 200;
// 最后一次绘制时钟的秒数
let lastSecond = 0;
// 时钟动起来
requestAnimationFrame(drawClock);
/**
* 绘制时钟函数
*/
function drawClock() {
let second = Math.round(Date.now() / 1000);
// 当前秒数大于上一次绘制时钟的秒数时,重绘时钟
if (second > lastSecond) {
lastSecond = second;
// 绘制背景网格
drawGrid('lightgray', 10, 10, ctx);
// 绘制时钟外观
drawFacade();
// 绘制时钟刻度
let sec = 60;
for (let i = 0; i < sec; i++) {
drawScale(x, y, radius, i, i * 6);
}
// 绘制时针、分针、秒针
drawPointer("h");
drawPointer("m");
drawPointer("s");
}
// 重绘
requestAnimationFrame(drawClock);
}
</script>
这段代码中使用了
Date.now()/1000
计算当前的秒数, 这是因为Date.now()
返回的是自 1970 年 1 月 1 日 00:00:00 到当前时间的毫秒数,除以1000并四舍五入就能得到秒数了。
\quad 接下来我们要讲述的是烟花的爆炸效果,其画面以夜空为背景,一朵朵烟花在空中绽放,呈现出美丽而短暂的视觉效果。烟花动画通常使用的是粒子系统或者类似的渲染技术,以模拟烟花爆炸后粒子的飞散效果。在散开的过程中,由于受到爆炸产生的作用力和动力、空气阻力等的影响,其运动轨迹比较复杂。
\quad 我们使用一组同心圆来代表烟花的形状,随着时间的推移,这组同心圆的半径越来越大,各个圆(火花)也越来越大。静止的烟花如下图所示:
\quad 这里我们使用Firework
类来描述烟花的的属性,Firework
类还提供了draw()
和update()
两个方法,draw()
方法负责绘制烟花,update()
方法负责随着时间而更新烟花的位置和大小等属性。
\quad 在本书的第四章 绘制曲线和路径 绘制多边形的章节中,我们讲述过正多边形各个顶点的计算方法,在这里我们仍使用该方法计算同心圆中圆心坐标。即:
Firework的源代码如下:
/**
* 烟花类
*/
class Firework {
constructor(x, y) {
this.x = x;
this.y = y;
// 火花数
this.sparkCount = 10;
// 火花大小
this.sparkSize = 2;
// 火花半径
this.sparkRadius = 10;
}
// 绘制烟花
draw() {
for (let i = 0; i < this.sparkCount; i++) {
let angle = i * 360 / this.sparkCount;
let cx = this.x + Math.cos(toRadians(angle)) * this.sparkRadius
let cy = this.y + Math.sin(toRadians(angle)) * this.sparkRadius
ctx.beginPath();
ctx.arc(cx, cy, this.sparkSize, Math.PI * 2, false);
ctx.closePath();
ctx.fillStyle = "#FF0000";
ctx.fill();
}
}
// 更新火花大小、位置、透明度
update() {
// 火花半径随速度扩散
this.sparkRadius ++;
// 火花大小随时间放大
this.sparkSize = this.sparkSize < 7 ? this.sparkSize + 0.02 : this.sparkSize;
return this.sparkRadius < 100;
}
}
\quad 在这里我们仍使用window.requestAnimationFrame(frame)
来实现动画。为了提供点击画布就产生烟花的功能,我们为canvas元素
增加了click
事件,在click
事件中可通过e.offsetX
和e.offsetY
获得当前的点击位置,每次点击后产生一个烟花,并存储至全局变量firworks
中,在frame()
函数中调用烟花对象的draw()
绘制烟花,并调用update()
方法计算烟花的新的位置和大小。源代码如下:
<script>
// 从页面中获取画板对象
let canvas = document.getElementById('canvas');
let ctx = canvas.getContext('2d');
let fireworks = [];
let times = 0;
// 黑色背景
ctx.fillStyle = "rgb(0,0,0)";
ctx.fillRect(0, 0, canvas.width, canvas.height);
// 帧动画
function frame() {
times ++;
if (times % 6 === 0) {
// 绘制黑色背景
ctx.fillStyle = "rgb(0,0,0)";
ctx.fillRect(0, 0, canvas.width, canvas.height);
drawGrid('lightgray', 0, 0, ctx);
// 绘制烟花
for (let i = fireworks.length - 1; i >=0; i--) {
fireworks[i].draw();
if(! fireworks[i].update()){
fireworks.splice(i, 1);
}
}
}
window.requestAnimationFrame(frame);
}
window.requestAnimationFrame(frame);
// 点击画布,产生烟花
canvas.addEventListener('click', function (e) {
fireworks.push(new Firework(e.offsetX, e.offsetY));
});
</script>
\quad 烟花播放时会受到爆炸产生的速度和地球动力影响的,为了模仿这个效果,在Firework中我们增加gravity
和speed
两个属性,烟花在散开后还会受到空气阻力的影响散开速度将会有所衰减,因此这里我们在增加衰减decay
属性,并在update()
根据这几个属性计算烟花的位置和大小。
class Firework {
constructor(x, y) {
……
// 重力
this.gravity = 1;
// 扩散速度
this.speed = 2;
// 扩散速度衰减率
this.decay = 0.98;
}
// 更新烟花大小、位置
update() {
// 烟花随重力落下
this.y += this.gravity;
// 烟花半径随速度扩散
this.sparkRadius = this.sparkRadius + this.speed;
// 烟花扩散速度衰减
this.speed = this.speed * this.decay;
// 火花大小随时间放大
this.sparkSize = this.sparkSize < 4 ? this.sparkSize + 0.02 : this.sparkSize;
return this.sparkRadius < 100;
}
}
\quad 长尾效果是指烟花爆炸后其运动轨迹会短暂停留在空中,就好像一个尾巴一样,要实现这样的效果,那么我们在绘制新的一帧的时候就不能完全清除屏幕,而是采用一个透明的黑色背景矩形框覆盖原有内容,每覆盖一次原有帧内容就变淡一些,逐渐会越来越淡直至完全消失。效果图如下:
只需要将原有清除屏幕的内容做一些调整,其源代码如下:
// 帧动画
function frame() {
if (times % 6 === 0 || debug === false) {
// 绘制黑色透明背景,可产生长尾效果
ctx.fillStyle = "rgba(0, 0, 0, 0.05)";
ctx.fillRect(0, 0, canvas.width, canvas.height);
drawGrid('lightgray', 0, 0, ctx);
// 绘制烟花
for (let i = fireworks.length - 1; i >= 0; i--) {
fireworks[i].draw();
if (!fireworks[i].update()) {
fireworks.splice(i, 1);
}
}
}
window.requestAnimationFrame(frame);
}
\quad 单个烟花的效果已经实现了,为了展示更丰富和更热闹的效果,我们还加增加一些随机效果,如下图所示:
\quad 这回我们增加了随机颜色,随机更多的烟花数,也让每个烟花随机产生火花数,其源代码如下:
class Firework {
constructor(x, y) {
// 火花数
this.sparkCount = getRandomNum(5, 14);
// 颜色
this.color = 15 * getRandomNum(0, 24);
}
}
// 随机在指定位置附近产生烟花
function randomFireworks(x, y) {
let num = getRandomNum(5, 10);
for (let i = 0; i < num; i++) {
fireworks.push(new Firework(x + getRandomNum(-50, 50), y + getRandomNum(-50, 50)));
}
}
\quad 由于采用了window.requestAnimationFrame(frame)
动画技术,同样的我们也会面临在不同屏幕刷新率的电脑上将会出现不同的播放速度的问题,这个问题我们仍采用帧率来解决。
\quad 这一次我们通过帧率来计算移动距离的思路来解决这个问题。我们知道fps代表每秒重绘帧的次数,常见的屏幕刷新率是60 H z Hz Hz,当fps等于30时,间隔时间为fpt等于60的两倍; 当fps等于120时,间隔时间为fpt等于60的0.5倍。由于 $距离 = 速度 \times 时间 $,利用该公式就可实现在不同帧率的电脑上相同的时间移动相同的距离的效果。
需再次修订update()
方法,将其更新移动距离的计算方法增加时间变量,修订后的源码如下:
let timeRatio = 1;
class Firework {
// 更新烟花大小、位置、透明度
update() {
// 烟花随重力落下
this.y += this.gravity * timeRatio;
// 烟花半径随速度扩散
this.sparkRadius = this.sparkRadius + this.speed * timeRatio;
// 烟花扩散速度衰减
this.speed = this.speed * this.decay;
// 火花大小随时间放大
this.sparkSize = this.sparkSize < 4 ? this.sparkSize + 0.02 : this.sparkSize;
// 火花颜色随时间减退
this.alpha -= 0.01 * timeRatio;
return this.alpha > 0.1;
}
}
// 计算帧率
function calculateFps() {
times++;
// 当前秒数大于上一次绘制时钟的秒数时,重新计算帧率
if (Date.now() >= lastTime + 1000) {
fps = times;
timeRatio = 60 / fps;
times = 0;
lastTime = Date.now();
}
}
改进后的烟花播放效果如下动图所示:
\quad 本节通过3个示例讲解了实现动画的基本方法,实现动画的基本过程包括:
应用实现动画的基本过程实现一个旋转的矩形,其效果图如下所示:
实现运动的小球的动画,要求小球可向各个方向运动,当碰壁后会自动反弹,其效果图如下所示:
技术要点:
\quad 小球在运动时有一个水平方向的速度speedX和一个垂直方向的速度speedY,在计算下一帧的图形数据时,为小球的圆心分别加上这两个值,从而实现小球的移动。
\quad 当小球圆心超过画布的边界时,其速度改变为其相反值。
▶ 系列教程及代码资料:http://GraphAnyWhere.com
▶ 图形系统开发实战课程:基础篇——图形系统概述
▶ 图形系统开发实战课程:基础篇——1.绘制基本图形
▶ 图形系统开发实战课程:基础篇——2.绘制文字
▶ 图形系统开发实战课程:基础篇——3.绘制图像
▶ 图形系统开发实战课程:基础篇——4.绘制曲线和路径
▶ 图形系统开发实战课程:基础篇——5.渲染效果
▶ 图形系统开发实战课程:基础篇——6.画布操作
▶ 图形系统开发实战课程:基础篇——7.变形操作
作者信息
作者 : 图形开发学院
CSDN: https://blog.csdn.net/2301_81340430?type=blog
官网:http://graphanywhere.com