如何在網頁中做出炫酷的動畫(使用Spine)

凌霄光發表於2019-03-03

屬性動畫和幀動畫

web中的動畫主要分為屬性動畫幀動畫兩種,屬性動畫是通過改變dom元素的屬性如寬高、字型大小或者transform的scale、rotate等屬性,在一段時間內,屬性值按照時間函式變化來實現的。幀動畫是通過在一段時間內按照一定速率替換圖片的方式來實現,這個和傳統的動畫方式一致。

幀動畫屬性動畫各有優缺點:屬性動畫不需要載入什麼資源,只需要不斷改變屬性值,觸發瀏覽器的重新計算和渲染就可以了。幀動畫能夠實現更為複雜的動畫效果,比如遊戲角色的技能特效等,但需要載入一些圖片。

web中的互動動畫特效一般都比較簡單,所以屬性動畫用的更多,幀動畫比較少。遊戲中的動畫效果追求絢麗,基本都會用幀動畫,部分會結合屬性動畫

AE和Spine

AE全稱After Effets,是Adobe公司推出的用於處理視訊和圖形的軟體。ui介面中的動畫效果很多都是用AE來做的。

如何在網頁中做出炫酷的動畫(使用Spine)

Spine是針對軟體和遊戲中的2d動畫的,製作動畫比AE更專業。遊戲中用的比較多。

如何在網頁中做出炫酷的動畫(使用Spine)

Lottie和Spine Runtime

Lottie是Airbnb推出的可以解析AE匯出的包含動畫資訊的json檔案的庫,支援Android、iOS,React Native等平臺。

Spine Runtime是Spine提供的Spine匯出的動畫解析的庫,支援各種遊戲引擎,如egret、cocos2d-x等。

Lottie渲染時需要提供一系列的圖片,渲染不同幀的時候會使用組合不同的圖片。Spine Runtime使用一個小圖片合成的大圖片,渲染時會取不同的部分來渲染。

Lottie
Spine Runtime

此外,Lottie支援svg、dom、canvas三種渲染方式,而Spine Runtime只支援canvas。

實際開發中,Lottie在應用中用的多,Spine Runtime在遊戲中用的多。但並不代表他們不能在另外的場景中使用。

在web應用中使用Spine Runtime

需求中涉及到動畫,設計師沒有使用AE,而是使用Spine來設計的。匯出的檔案也是Spine特有的格式,於是我就對Spine進行了調研。

經過調研我發現Spine的Runtime中有Html Canvas,這就是他可用在web應用中的基礎。

如何在網頁中做出炫酷的動畫(使用Spine)

我把demo下下來看了一下,通過閱讀程式碼,替換對應的資原始檔,刪減部分無用程式碼之後,對Spine Canvas Runtime的使用有了一些心得。

動畫資源

Spine匯出的檔案有3個,xxx.atlas、xxx.json、xxx.png

如何在網頁中做出炫酷的動畫(使用Spine)

xxx.json是動畫的描述檔案,分為skeleton、bones、slots、skins、animations這5部分

如何在網頁中做出炫酷的動畫(使用Spine)

我們沒必要去詳細瞭解,只需要知道這裡的animations下有一個叫做animation的動畫就可以了。

xxx.png是圖片檔案,因為圖片整合到了一起,所有有一個xxx.atlas檔案來描述哪個小圖片在什麼地方。

如何在網頁中做出炫酷的動畫(使用Spine)

資源就這3個檔案,接下來就是動畫實現的程式碼了。

動畫實現程式碼

經過分析,整體流程就是載入資源後,通過不斷的重繪來顯示一幀幀的圖片,圖片的更新是通過時間的毫秒數來驅動的。

不斷重繪的邏輯:

如何在網頁中做出炫酷的動畫(使用Spine)
如何在網頁中做出炫酷的動畫(使用Spine)

改變繪製內容的邏輯:

如何在網頁中做出炫酷的動畫(使用Spine)

每次繪製傳入兩次繪製的時間差,spine runtime會計算出當前應該渲染的內容是什麼。

上面是核心的不斷重繪的機制和更新渲染內容的機制,整體的流程如下:

如何在網頁中做出炫酷的動畫(使用Spine)

先載入資源,然後不斷re-render。

整體程式碼如下:


<!-- saved from url=(0068)http://esotericsoftware.com/files/runtimes/spine-ts/examples/canvas/ -->
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=windows-1252">
<script src="./js/spine-canvas.js"></script>
<style>
	* { margin: 0; padding: 0; }
	body, html { height: 100% }
	canvas { position: absolute; width: 100% ;height: 100%; }
</style>
</head>
<body>
<canvas id="canvas" width="398" height="588"></canvas>

<script>


var lastFrameTime = Date.now() / 1000;
var canvas, context;
var assetManager;
var skeleton, state, bounds;
var skeletonRenderer;

// var skelName = "spineboy-ess";
var skelName = "pk_list_flash";
// var animName = "walk";
var animName = "animation";


function init () {
	canvas = document.getElementById("canvas");
	canvas.width = window.innerWidth;
	canvas.height = window.innerHeight;
	context = canvas.getContext("2d");

	skeletonRenderer = new spine.canvas.SkeletonRenderer(context);
	// enable debug rendering
	skeletonRenderer.debugRendering = false;
	// enable the triangle renderer, supports meshes, but may produce artifacts in some browsers
	skeletonRenderer.triangleRendering = false;

	assetManager = new spine.canvas.AssetManager();

	assetManager.loadText("assets/" + skelName + ".json");
	assetManager.loadText("assets/" + skelName.replace("-pro", "").replace("-ess", "") + ".atlas");
	assetManager.loadTexture("assets/" + skelName.replace("-pro", "").replace("-ess", "") + ".png");

	requestAnimationFrame(run);
}

function run () {
	if (assetManager.isLoadingComplete()) {
		var data = loadSkeleton(skelName, animName, "default");
		skeleton = data.skeleton;
		state = data.state;
		bounds = data.bounds;
		requestAnimationFrame(render);
	} else {
		requestAnimationFrame(run);
	}
}

function loadSkeleton (name, initialAnimation, skin) {
	if (skin === undefined) skin = "default";

	// Load the texture atlas using name.atlas and name.png from the AssetManager.
	// The function passed to TextureAtlas is used to resolve relative paths.
	atlas = new spine.TextureAtlas(assetManager.get("assets/" + name.replace("-pro", "").replace("-ess", "") + ".atlas"), function(path) {
		return assetManager.get("assets/" + path);
	});

	// Create a AtlasAttachmentLoader, which is specific to the WebGL backend.
	atlasLoader = new spine.AtlasAttachmentLoader(atlas);

	// Create a SkeletonJson instance for parsing the .json file.
	var skeletonJson = new spine.SkeletonJson(atlasLoader);

	// Set the scale to apply during parsing, parse the file, and create a new skeleton.
	var skeletonData = skeletonJson.readSkeletonData(assetManager.get("assets/" + name + ".json"));
	var skeleton = new spine.Skeleton(skeletonData);
	skeleton.flipY = true;
	var bounds = calculateBounds(skeleton);
	skeleton.setSkinByName(skin);

	// Create an AnimationState, and set the initial animation in looping mode.
	var animationState = new spine.AnimationState(new spine.AnimationStateData(skeleton.data));
	animationState.setAnimation(0, initialAnimation, true);
	animationState.addListener({
		event: function(trackIndex, event) {
			// console.log("Event on track " + trackIndex + ": " + JSON.stringify(event));
		},
		complete: function(trackIndex, loopCount) {
			// console.log("Animation on track " + trackIndex + " completed, loop count: " + loopCount);
		},
		start: function(trackIndex) {
			// console.log("Animation on track " + trackIndex + " started");
		},
		end: function(trackIndex) {
			// console.log("Animation on track " + trackIndex + " ended");
		}
	})

	// Pack everything up and return to caller.
	return { skeleton: skeleton, state: animationState, bounds: bounds };
}

function calculateBounds(skeleton) {
	var data = skeleton.data;
	skeleton.setToSetupPose();
	skeleton.updateWorldTransform();
	var offset = new spine.Vector2();
	var size = new spine.Vector2();
	skeleton.getBounds(offset, size, []);
	return { offset: offset, size: size };
}

function render () {
	var now = Date.now() / 1000;
	var delta = now - lastFrameTime;
	lastFrameTime = now;

	resize();
	
	state.update(delta);
	state.apply(skeleton);
	skeleton.updateWorldTransform();
	skeletonRenderer.draw(skeleton);

	requestAnimationFrame(render);
}

function resize () {
	var w = canvas.clientWidth;
	var h = canvas.clientHeight;
	if (canvas.width != w || canvas.height != h) {
		canvas.width = w;
		canvas.height = h;
	}

	// magic
	var centerX = bounds.offset.x + bounds.size.x / 2;
	var centerY = bounds.offset.y + bounds.size.y / 2;
	var scaleX = bounds.size.x / canvas.width;
	var scaleY = bounds.size.y / canvas.height;
	var scale = Math.max(scaleX, scaleY) * 1.2;
	if (scale < 1) scale = 1;
	var width = canvas.width * scale;
	var height = canvas.height * scale;

	context.setTransform(1, 0, 0, 1, 0, 0);
	context.scale(1 / scale, 1 / scale);
	context.translate(-centerX, -centerY);
	context.translate(width / 2, height / 2);
}

(function() {
	init();
}());

</script>
</body></html>
複製程式碼

總結

web中的動畫有屬性動畫和幀動畫兩種,幀動畫常用的庫有Lottie和Spine Runtime,用哪一種取決於動效師使用的是AE還是Spine,其中Spine多用於遊戲的動畫。

從圖片資源的管理方式、支援的渲染方式和平臺這幾個方面比較了Lottie和Spine Runtime的區別:Spine 多用於遊戲,圖片資源整合到一起並且提供atlas檔案來標明對應圖片位置,支援canvas的渲染方式,支援各種遊戲引擎。Lottie多用於應用,圖片資源分開存放,支援canvas、svg、dom三種渲染方式,並且支援Android、ios、React Native等平臺。僅從canvas角度看,兩者區別並不大。

因為動效師選擇了Spine來設計動效,所以我調研了Spine Runtime的動畫實現方案,研究了Spine的動畫資源和Spine Cavas Runtime的程式碼實現、執行流程,全部程式碼見github

相關文章