Đồ họa và hình ảnh động là những khía cạnh cơ bản nhất của bất kỳ trò chơi nào, vì vậy trong
bài này, tôi bắt đầu bằng một tổng quan ngắn gọn về API 2D Canvas (Canvas 2D API), tiếp theo
là m ột thảo luận về việc thực hiện hình ảnh động trung tâm của Snail Bait. Trong bài này, bạn sẽ
học cách:
Vẽ các hình ảnh và các hình khối đồ họa thô sơ vào một canvas (khung nền ảnh).
Tạo các hình ảnh động mượt mà, không bị giật.
Thực hiện vòng lặp trong trò chơi.
Theo dõi tốc độ hình ảnh động tính theo số khung hình m ỗi giây
Cuộn background khi di chuyển trong trò chơi.
Sử dụng thị sai để mô phỏng ba chiều.
Thực hiện chuyển động theo thời gian.
18 trang |
Chia sẻ: lylyngoc | Lượt xem: 1597 | Lượt tải: 1
Bạn đang xem nội dung tài liệu Lập trình game 2D trên HTML5, Phần 2: Đồ họa và ảnh động, để tải tài liệu về máy bạn click vào nút DOWNLOAD ở trên
Lập trình game 2D trên HTML5, Phần 2: Đồ họa và
ảnh động
Đồ họa và hình ảnh động là những khía cạnh cơ bản nhất của bất kỳ trò chơi nào, vì vậy trong
bài này, tôi bắt đầu bằng một tổng quan ngắn gọn về API 2D Canvas (Canvas 2D API), tiếp theo
là một thảo luận về việc thực hiện hình ảnh động trung tâm của Snail Bait. Trong bài này, bạn sẽ
học cách:
Vẽ các hình ảnh và các hình khối đồ họa thô sơ vào một canvas (khung nền ảnh).
Tạo các hình ảnh động mượt mà, không bị giật.
Thực hiện vòng lặp trong trò chơi.
Theo dõi tốc độ hình ảnh động tính theo số khung hình mỗi giây
Cuộn background khi di chuyển trong trò chơi.
Sử dụng thị sai để mô phỏng ba chiều.
Thực hiện chuyển động theo thời gian.
Kết quả cuối cùng của mã được thảo luận trong bài này được thể hiện trong Hình 1:
Hình 1. Cuộn background khi di chuyển và theo dõi tốc độ khung hình
Background và các bậc thềm cuộn theo chiều ngang. Các bậc thềm ở phía trước, do đó, chúng di
chuyển nhanh hơn đáng kể hơn so với nền sau, tạo ra một hiệu ứng thị sai nhẹ. Khi trò chơi bắt
đầu, background cuộn từ phải sang trái. Đến cuối màn, background và bậc thềm đảo chiều
chuyển động.
Ở giai đoạn phát triển, nhân vật (trong trò chơi) không di chuyển. Ngoài ra, trò chơi còn chưa có
phát hiện va chạm, do đó, nhân vật lơ lửng giữa không trung khi không có bậc thềm nào ở dưới.
Cuối cùng, các biểu tượng ở phía trên và sang bên trái khung nền ảnh của trò chơi sẽ cho biết số
mạng sống còn lại của nhân vật (như thể hiện trong Hình 1 trong bài viết đầu tiên của loạt bài
này). Tạm thời lúc này, trò chơi hiển thị tốc độ hình ảnh động theo số khung hình trên mỗi giây ở
vị trí đó.
Đồ họa trong chế độ hiển thị ngay lập tức
Canvas (Khung nền ảnh) là một hệ thống đồ họa trong chế độ hiển thị ngay lập tức có nghĩa là
nó vẽ ngay lập tức những gì bạn quy định và sau đó quên ngay lập tức. Các hệ thống đồ họa
khác, chẳng hạn như Scalable Vector Graphics (SVG – Đồ họa Vectơ có thể điều chỉnh quy mô),
thực hiện đồ họa trong chế độ-giữ lại, có nghĩa là chúng duy trì một danh sách các đối tượng cần
vẽ. Do không cần chi phí duy trì một danh sách hiển thị, nên Canvas nhanh hơn SVG; tuy nhiên,
nếu bạn muốn duy trì một danh sách các đối tượng để người dùng có thể thao tác, bạn phải tự
mình thực hiện chức năng đó trong Canvas.
Trước khi tiếp tục, bạn có thể muốn thử trò chơi như hiển thị trong Hình 1; các mã sẽ dễ hiểu
hơn nếu bạn viết nó. (Xem phần Tải về để chạy thử trò Snail Bait trong bài này).
Tổng quan về Canvas của HTML5
Bối cảnh 2D của Canvas cung cấp một API đồ họa có phạm vi rộng để cho phép bạn thực hiện
mọi thứ từ các trình soạn thảo văn bản đến các trò chơi video. Vào thời điểm viết bài này, API đó
đã có nhiều hơn 30 phương thức, nhưng Snail Bait chỉ sử dụng một ít trong số đó, được thể hiện
trong Bảng 1:
Bảng 1. Các phương thức bối cảnh 2D của Canvas được Snail Bait sử dụng
Phương thức Mô tả
drawImage() Vẽ tất cả hoặc một phần của một hình ảnh tại một vị trí cụ thể trong một canvas.
Cũng có thể vẽ một canvas khác hoặc một khung hình từ một phần tử video.
save() Lưu các thuộc tính bối cảnh vào một ngăn xếp.
restore() Lấy các thuộc tính bối cảnh ra khỏi ngăn xếp và áp dụng chúng vào bối cảnh đó.
strokeRect()Vẽ một hình chữ nhật rỗng.
fillRect() Vẽ một hình chữ nhật đặc (được tô kín).
translate()
Chuyển dịch hệ tọa độ. Đây là một phương thức mạnh, có ích trong nhiều tình
huống khác nhau. Tất cả việc cuộn di chuyển trong Snail Bait được thực hiện
bằng một cuộc gọi phương thức này.
Đồ họa theo đường
Như Cocoa của Apple và Illustrator của Adobe, API Canvas là đồ họa theo đường, có nghĩa là
bạn vẽ các hình khối đồ họa thô sơ vào một canvas bằng cách vạch ra một tuyến đường và sau đó
vẽ một đường mảnh hoặc tô đặc theo nó. Các phương thức strokeRect() và fillRect() là các
phương thức tiện lợi, tương ứng vẽ một hình chữ nhật rỗng hoặc vẽ một hình chữ nhật đặc.
Mọi thứ trong Snail Bait, ngoại trừ các bậc thềm thì đều là một hình ảnh. Background gồm có
nhân vật đang chạy, tất cả những người tốt và kẻ xấu, đều là những hình ảnh mà trò chơi vẽ bằng
phương thức drawImage().
Cuối cùng Snail Bait sẽ sử dụng một spritesheet — một hình ảnh duy nhất có chứa tất cả đồ họa
của trò chơi — nhưng bây giờ tôi sử dụng các hình ảnh riêng biệt cho background và nhân vật
đang chạy. Tôi vẽ nhân vật đang chạy bằng một hàm được hiển thị trong Liệt kê 1:
Liệt kê 1. Vẽ cô bé đang chạy
function drawRunner() {
context.drawImage(runnerImage, //
image
STARTING_RUNNER_LEFT, //
canvas left
calculatePlatformTop(runnerTrack) - RUNNER_HEIGHT); //
canvas top
}
Hàm drawRunner() chuyển ba đối số cho phương thức drawImage(): một hình ảnh và các tọa
độ trái và tọa độ đỉnh để vẽ hình ảnh trong canvas ở đó. Tọa độ trái là một hằng số, trong khi tọa
độ đỉnh được xác định bởi bậc thềm có nhân vật đang chạy ở trên đó.
Tôi vẽ background theo cách tương tự như Liệt kê 2 minh họa:
Liệt kê 2. Vẽ background
function drawBackground() {
context.drawImage(background, 0, 0);
}
Phương thức drawImage() đa năng
Bạn có thể vẽ toàn bộ một hình ảnh hoặc bất kỳ vùng hình chữ nhật nào trong một hình ảnh, ở
bất cứ nơi nào bên trong một canvas bằng phương thức drawImage() của bối cảnh 2D Canvas,
với tùy chọn co giãn hình ảnh trong lúc vẽ. Ngoài các hình ảnh, bạn cũng có thể vẽ những nội
dung của một canvas khác hoặc khung hình hiện tại của một phần tử video bằng phương thức
drawImage(). Dù chỉ là một phương thức, nhưng phương thức drawImage() tạo điều kiện thuận
lợi cho việc thực hiện đơn giản các ứng dụng thú vị nhưng lại khó thực hiện chẳng hạn như phần
mềm chỉnh sửa video.
Hàm drawBackground() trong Liệt kê 2 vẽ background tại tọa độ (0,0) trong canvas. Sau đó
trong bài này, tôi sửa đổi hàm đó để cuộn di chuyển nền sau này.
Vẽ các bậc thềm, không phải là hình ảnh, đòi hỏi phải sử dụng nhiều hơn các API Canvas, như
thể hiện trong Liệt kê 3:
Liệt kê 3. Vẽ các bậc thềm
var platformData = [
// Screen 1.......................................................
{
left: 10,
width: 230,
height: PLATFORM_HEIGHT,
fillStyle: 'rgb(150,190,255)',
opacity: 1.0,
track: 1,
pulsate: false,
},
...
],
...
function drawPlatforms() {
var data, top;
context.save(); // Save the current state of the context
context.translate(-platformOffset,
0); // Translate the coord system for all platforms
for (var i=0; i < platformData.length; ++i) {
data = platformData[i];
top = calculatePlatformTop(data.track);
context.lineWidth = PLATFORM_STROKE_WIDTH;
context.strokeStyle = PLATFORM_STROKE_STYLE;
context.fillStyle = data.fillStyle;
context.globalAlpha = data.opacity;
context.strokeRect(data.left, top, data.width, data.height);
context.fillRect (data.left, top, data.width, data.height);
}
context.restore(); // Restore context state saved above
}
Mã JavaScript trong Liệt kê 3 định nghĩa một mảng có tên là platformData. Mỗi đối tượng
trong mảng đại diện cho siêu dữ liệu mô tả một bậc thềm riêng.
Hàm drawPlatforms() sử dụng phương thức strokeRect() và fillRect() của bối cảnh
Canvas để vẽ các bậc thềm hình chữ nhật. Sử dụng các đặc tính của những hình chữ nhật đó —
được lưu trữ trong các đối tượng trong mảng platformData — để thiết lập kiểu tô đặc của bối
cảnh và thuộc tính globalAlpha, thiết lập mức độ mờ của bất cứ thứ gì để sau đó bạn vẽ trong
canvas.
Lời gọi hàm context.translate() chuyển dịch hệ thống tọa độ của canvas — được mô tả
trong Hình 2 — theo một số điểm ảnh (pixel) đã quy định theo chiều ngang. Việc chuyển dịch đó
và các giá trị thiết lập thuộc tính chỉ là tạm thời vì chúng được thực hiện giữa các lời gọi đến
context.save() và context.restore().
Hình 2. Hệ thống tọa độ Canvas mặc định
Theo mặc định, gốc của hệ tọa độ là ở góc trên bên trái của canvas. Bạn có thể di chuyển gốc của
hệ tọa độ bằng context.translate().
Tôi thảo luận về việc cuộn di chuyển background bằng context.translate() trong phần Cuộn
di chuyển nền sau. Nhưng vào lúc này, bạn đã biết gần như mọi thứ cần biết về Canvas HTML5
để thực hiện Snail Bait. Với phần còn lại của loạt bài này, tôi sẽ tập trung vào các khía cạnh khác
về phát triển trò chơi với HTML5, bắt đầu với hình ảnh động.
Về đầu trang
Hình ảnh động trong HTML5
Về cơ bản, việc thực hiện hình ảnh động rất đơn giản: Bạn vẽ nhiều lần một chuỗi các hình ảnh
để làm cho nó xuất hiện như thể các đối tượng đang hoạt động theo một cách nào đó. Điều đó có
nghĩa là bạn phải thực hiện một vòng lặp để vẽ một hình ảnh theo định kỳ.
Theo truyền thống, các vòng lặp hình ảnh động đã được thực hiện trong JavaScript bằng
setTimeout() hoặc như minh họa trong Liệt kê 4, bằng setInterval():
Liệt kê 4. Thực hiện các hình ảnh động bằng setInterval()
setInterval( function (e) { // Don't do this for time-critical animations
animate(); // A function that draws the current animation
frame
}, 1000 / 60); // Approximately 60 frames/second (fps)
Lời khuyên
Đừng bao giờ sử dụng setTimeout() hay setInterval() cho các hình ảnh động hạn chế thời
gian.
Mã trong Liệt kê 4 chắc chắn sẽ tạo ra một hình ảnh động bằng cách liên tục gọi một hàm
animate() để vẽ khung hình của hình ảnh động tiếp theo; tuy nhiên, bạn có thể không hài lòng
với các kết quả đó, vì setInterval() và setTimeout() không biết gì về hình ảnh động. (Lưu ý:
Bạn phải thực hiện hàm animate(); nó không phải là một phần của API Canvas).
Trong Liệt kê 4, tôi thiết lập khoảng thời gian 1000/60 mili giây, tương đương với khoảng 60
khung hình mỗi giây. Con số đó là ước tính tốt nhất của tôi về một tốc độ khung hình tối ưu và
có thể nó không phải là một tốc độ rất tốt; tuy nhiên, vì setInterval() và setTimeout() không
biết bất cứ điều gì về hình ảnh động, nên tôi phải quy định tốc độ khung hình. Sẽ là tốt hơn nếu
thay vào đó trình duyệt quy định tốc độ khung hình, vì chắc chắn nó biết tốt hơn tôi khi nào cần
vẽ khung hình ảnh động tiếp theo.
Có một nhược điểm thậm chí còn nghiêm trọng hơn với việc sử dụng setTimeout và
setInterval(). Mặc dù bạn chuyển cho các phương thức đó những khoảng thời gian tính bằng
mili giây, nhưng các phương thức đó không làm chính xác đến milli giây. Trong thực tế, theo đặc
tả của HTML, các phương thức này có thể kéo dài thoải mái thêm khoảng thời gian mà bạn quy
định — vì nỗ lực để bảo tồn tài nguyên.
Để tránh những nhược điểm này, bạn không nên sử dụng setTimeout() và setInterval() cho
các hình ảnh động hạn chế thời gian; thay vào đó, bạn nên sử dụng phương thức
requestAnimationFrame().
requestAnimationFrame()
Trong đặc tả Kiểm soát định thời gian cho hình ảnh động dựa trên kịch bản lệnh (xem phần Tài
nguyên), W3C định nghĩa một phương thức dựa trên đối tượng window có tên là
requestAnimationFrame(). Không giống như setTimeout() hay setInterval(), phương
thức requestAnimationFrame() đặc biệt được dành cho việc thực hiện hình ảnh động. Do đó,
nó không bị một nhược điểm nào liên quan đến setTimeout() và setInterval(). Nó cũng dễ
sử dụng, như Liệt kê 5 minh họa:
Liệt kê 5. Thực hiện hình ảnh động bằng requestAnimationFrame()
function animate(time) { // Animation loop
draw(time); // A function that draws the current
animation frame
requestAnimationFrame(animate); // Keep the animation going
};
requestAnimationFrame(animate); // Start the animation
Bạn chuyển cho phương thức requestAnimationFrame() một tài liệu tham khảo đến một hàm
gọi lại và khi trình duyệt đã sẵn sàng vẽ khung hình ảnh động tiếp theo, nó gọi hàm gọi lại này.
Để duy trì hình ảnh động, hàm gọi lại cũng gọi phương thức requestAnimationFrame().
Như bạn có thể thấy trong Liệt kê 5, trình duyệt chuyển một tham số time tới hàm gọi lại của
bạn. Bạn có thể tự hỏi rằng chính xác tham số time có nghĩa là gì. Đó có phải là thời điểm hiện
tại không? Có phải là thời điểm mà lúc đó trình duyệt sẽ vẽ khung hình ảnh động tiếp theo
không?
Thật đáng ngạc nhiên, không có định nghĩa nào về tham số time đó. Điều duy nhất mà bạn có thể
tin chắc là đối với bất kỳ trình duyệt đã cho nào, tham số time luôn luôn chỉ biểu diễn thời gian
mà thôi; do đó, bạn có thể sử dụng nó để tính toán thời gian trôi qua giữa các khung hình, như tôi
minh họa trong Tính toán tốc độ hình ảnh động theo số khung hình mỗi giây (fps).
Một polyfill của requestAnimationFrame()
Trên nhiều phương diện, HTML5 là một thiên đường viễn tưởng của lập trình viên. Thoát khỏi
các API độc quyền, các nhà phát triển sử dụng HTML5 để thực hiện các ứng dụng chạy trên
nhiều nền tảng trong các trình duyệt phổ biến. Các đặc tả tiến bộ nhanh chóng, liên tục kết hợp
với công nghệ mới và tinh chỉnh chức năng hiện có.
Các Polyfill: Lập trình cho tương lai
Trong quá khứ, hầu hết các phần mềm nhiều nền tảng đã được thực hiện với mẫu thức chung nhỏ
nhất. Các Polyfill thay đổi hoàn toàn khái niệm đó bằng cách cho phép bạn truy cập vào các tính
năng nâng cao nếu chúng có sẵn và quay lại với bản thực hiện kém khả năng hơn khi cần.
Tuy nhiên, công nghệ mới thường đi vào đặc tả kỹ thuật theo cách của mình, thông qua các chức
năng đặc trưng của trình duyệt hiện có. Các nhà cung cấp trình duyệt thường thêm phần tiền tố
vào các chức năng như vậy để nó không can nhiễu với bản thực hiện của trình duyệt khác; ví dụ;
phương thức requestAnimationFrame(), ban đầu đã được Mozilla thực hiện như
mozRequestAnimationFrame(). Sau đó, WebKit đã thực hiện nó với tên là hàm
webkitRequestAnimationFrame(). Cuối cùng, W3C đã tiêu chuẩn hóa nó thành
requestAnimationFrame().
Các bản triển khai thực hiện có thêm tiền tố chỉ nhà cung cấp và sự hỗ trợ khác nhau cho việc
thực hiện các tiêu chuẩn làm cho chức năng mới khó sử dụng, do đó, cộng đồng HTML5 đã phát
minh ra một thứ được gọi là một polyfill. Các polyfill xác định mức hỗ trợ của trình duyệt cho
một tính năng cụ thể và cung cấp cho bạn quyền truy cập trực tiếp nếu trình duyệt thực hiện nó
hoặc một bản thực hiện tạm thời để bắt chước chức năng tiêu chuẩn đến mức tốt nhất có thể
được.
Các polyfill sử dụng đơn giản, nhưng có thể phức tạp khi triển khai thực hiện. Liệt kê 6 cho thấy
việc thực hiện một polyfill dành cho requestAnimationFrame():
Liệt kê 6. Polyfill của requestNextAnimationFrame()
// Reprinted from Core HTML5 Canvas
window.requestNextAnimationFrame =
(function () {
var originalWebkitRequestAnimationFrame = undefined,
wrapper = undefined,
callback = undefined,
geckoVersion = 0,
userAgent = navigator.userAgent,
index = 0,
self = this;
// Workaround for Chrome 10 bug where Chrome
// does not pass the time to the animation function
if (window.webkitRequestAnimationFrame) {
// Define the wrapper
wrapper = function (time) {
if (time === undefined) {
time = +new Date();
}
self.callback(time);
};
// Make the switch
originalWebkitRequestAnimationFrame =
window.webkitRequestAnimationFrame;
window.webkitRequestAnimationFrame = function (callback, element) {
self.callback = callback;
// Browser calls the wrapper and wrapper calls the callback
originalWebkitRequestAnimationFrame(wrapper, element);
}
}
// Workaround for Gecko 2.0, which has a bug in
// mozRequestAnimationFrame() that restricts animations
// to 30-40 fps.
if (window.mozRequestAnimationFrame) {
// Check the Gecko version. Gecko is used by browsers
// other than Firefox. Gecko 2.0 corresponds to
// Firefox 4.0.
index = userAgent.indexOf('rv:');
if (userAgent.indexOf('Gecko') != -1) {
geckoVersion = userAgent.substr(index + 3, 3);
if (geckoVersion === '2.0') {
// Forces the return statement to fall through
// to the setTimeout() function.
window.mozRequestAnimationFrame = undefined;
}
}
}
return window.requestAnimationFrame ||
window.webkitRequestAnimationFrame ||
window.mozRequestAnimationFrame ||
window.oRequestAnimationFrame ||
window.msRequestAnimationFrame ||
function (callback, element) {
var start,
finish;
window.setTimeout( function () {
start = +new Date();
callback(start);
finish = +new Date();
self.timeout = 1000 / 60 - (finish - start);
}, self.timeout);
};
}
)
();
Polyfill: Định nghĩa
Từ polyfill là một từ ghép của hai từ polymorphism (đa hình) và backfill. Giống như đa hình, các
polyfill chọn mã thích hợp trong thời gian chạy và chúng lấp lại chức năng còn thiếu.
Polyfill được thực hiện trong Liệt kê 6 gắn một hàm có tên là requestNextAnimationFrame()
vào đối tượng window. Việc đưa Next vào tên hàm là để phân biệt nó với hàm
requestAnimationFrame() cơ bản.
Hàm mà polyfill gán cho requestNextAnimationFrame() hoặc là requestAnimationFrame()
nếu trình duyệt hỗ trợ nó hoặc là bản thực hiện có tên tiền tố chỉ nhà cung cấp. Nếu trình duyệt
không hỗ trợ cả hai thứ đó, hàm này sẽ là một bản thực hiện tạm thời, sử dụng setTimeout() để
bắt chước requestAnimationFrame() một cách tốt nhất có thể.
Gần như tất cả tính phức tạp của polyfill đều liên quan đến việc giải quyết hai lỗi và tạo thành
mã trước câu lệnh return. Lỗi đầu tiên liên quan đến Chrome 10, đó là nó chuyển một giá trị
undefined (không xác định) cho tham số time. Lỗi thứ hai liên quan đến Firefox 4.0, đó là nó
hạn chế tốc độ khung hình ở 35-40 khung hình mỗi giây.
Mặc dù bản thực hiện polyfill của requestNextAnimationFrame() là thú vị, nhưng không cần
thiết phải hiểu nó; thay vào đó, tất cả mọi thứ mà bạn cần biết là cách sử dụng nó, như tôi sẽ
minh họa trong phần tiếp theo.
Về đầu trang
Vòng lặp trò chơi
Bây giờ các điều tiên quyết về đồ họa và hình ảnh động đã xong, đây là lúc đưa Snail Bait vào
chuyển động. Để bắt đầu, tôi đưa mã JavaScript dùng cho requestNextAnimationFrame() vào
HTML của trò chơi, như thể hiện trong 7:
Liệt kê 7. HTML
...
...
Liệt kê 8 cho thấy vòng lặp hình ảnh động của trò chơi, thường được gọi là vòng lặp trò chơi:
Liệt kê 8. Vòng lặp trò chơi
var fps;
function animate(now) {
fps = calculateFps(now);
draw();
requestNextAnimationFrame(animate);
}
function startGame() {
requestNextAnimationFrame(animate);
}
Hàm startGame(), do trình xử lý sự kiện onload của hình ảnh nền sau gọi ra, khởi động trò
chơi bằng cách gọi polyfill requestNextAnimationFrame(). Khi đến thời gian vẽ khung hình
của hình ảnh động đầu tiên của trò chơi, trình duyệt gọi hàm animate().
Hàm animate() tính toán tốc độ khung hình của hình ảnh động, dựa vào giá trị hiện tại của tham
số time. (Xem requestAnimationFrame() để biết thêm về giá trị time). Sau khi tính toán tốc
độ khung hình, hàm animate() sẽ gọi một hàm draw() để vẽ khung hình của hình ảnh động tiếp
theo. Sau đó, hàm animate() gọi requestNextAnimationFrame() để duy trì hình ảnh động.
Tính toán tốc độ hình ảnh động theo số khung hình mỗi giây (fps)
Liệt kê 9 cho thấy cách Snail Bait tính toán tốc độ khung hình của mình và cách nó cập nhật giá
trị đọc tốc độ khung hình được thể hiện trong Hình 1:
Liệt kê 9. Tính fps và cập nhật phần tử fps
var lastAnimationFrameTime = 0,
lastFpsUpdateTime = 0,
fpsElement = document.getElementById('fps');
function calculateFps(now) {
var fps = 1000
/ (now -
lastAnimationFrameTime);
lastAnimationFrameTime = now;
if (now - lastFpsUpdateTime > 1000) {
lastFpsUpdateTime = now;
fpsElement.innerHTML = fps.toFixed(0) + ' fps';
}
return fps;
}
Tốc độ khung hình chỉ đơn giản là khoảng thời gian trôi qua tính từ khung hình của hình ảnh
động cuối cùng, vì vậy bạn có thể lý luận rằng đó là khung hình mỗi giây thay vì nhiều khung
hình mỗi giây và điều đó làm cho nó không có vẻ nhanh chút nào. Bạn có thể theo cách tiếp cận
chặt chẽ hơn và duy trì một tốc độ khung hình trung bình qua vài khung hình, nhưng tôi đã thấy
là không cần thiết; thực vậy, thời gian trôi qua kể từ khung hình của hình ả