Last active
February 25, 2023 14:59
-
-
Save nwellis/30f5cc581f94c6fde062df6a8153790c to your computer and use it in GitHub Desktop.
Renders a spine in an HTML canvas element with WebGL enabled
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
--- | |
export type Props = { | |
id?: string | |
assets: { | |
jsonUri: string | |
atlasUri: string | |
skin?: string | |
} | |
} | |
const { assets } = Astro.props; | |
--- | |
<script> | |
import { SpineRenderer } from "../scripts/SpineRenderer" | |
new SpineRenderer("spine-canvas", false) | |
</script> | |
<canvas | |
class="w-full h-full" | |
id="spine-canvas" | |
data-json={assets.jsonUri} | |
data-atlas={assets.atlasUri} | |
data-skin={assets.skin} | |
/> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { AnimationState, AnimationStateData, AssetManager, AtlasAttachmentLoader, ManagedWebGLRenderingContext, Matrix4, PolygonBatcher, Shader, Skeleton, SkeletonJson, SkeletonRenderer, Skin, Vector2 } from "@esotericsoftware/spine-webgl"; | |
/** | |
* Helper class to render a spine in a canvas. | |
* | |
* TODO: Create as a custom Canvas Element | |
* | |
* @see https://github.com/EsotericSoftware/spine-runtimes/blob/3.7/spine-ts/webgl/example/index.html | |
* @see http://esotericsoftware.com/spine-runtimes | |
*/ | |
export class SpineRenderer { | |
static readonly DEFAULT_SKIN = "default" | |
readonly canvas: HTMLCanvasElement | |
readonly webGL: ManagedWebGLRenderingContext | |
readonly mvp = new Matrix4() | |
readonly assetManager: AssetManager | |
readonly asset: { | |
jsonUri: string | |
atlasUri: string | |
skin?: string | |
} | |
readonly assetKey: string | |
readonly shader: Shader | |
readonly batcher: PolygonBatcher | |
readonly skeletonRenderer: SkeletonRenderer | |
skeleton: ReturnType<typeof this.loadSkeleton> | |
constructor( | |
readonly canvasId: string, | |
readonly debug: boolean, | |
readonly config: Partial<{ alpha: boolean }> = { | |
alpha: false, | |
} | |
) { | |
this.canvas = document.getElementById(this.canvasId) as HTMLCanvasElement | |
this.asset = { | |
jsonUri: this.canvas.dataset.json, | |
atlasUri: this.canvas.dataset.atlas, | |
skin: this.canvas.dataset.skin, | |
} | |
this.webGL = new ManagedWebGLRenderingContext(this.getWebGL()) | |
this.mvp.ortho2d(0, 0, this.canvas.width - 1, this.canvas.height - 1); | |
// Create a simple shader, mesh, model-view-projection matrix and SkeletonRenderer. | |
this.shader = Shader.newTwoColoredTextured(this.webGL) | |
this.batcher = new PolygonBatcher(this.webGL) | |
this.skeletonRenderer = new SkeletonRenderer(this.webGL) | |
this.assetManager = new AssetManager(this.webGL) | |
this.assetManager.loadJson(this.asset.jsonUri) | |
this.assetManager.loadTextureAtlas(this.asset.atlasUri) | |
const jsonFilename = this.asset.jsonUri.split("/").at(-1) | |
this.assetKey = jsonFilename.replace(/\.json/i, "") | |
requestAnimationFrame(this.load) | |
} | |
getWebGL() { | |
const webGL = ( | |
this.canvas.getContext("webgl", this.config) || this.canvas.getContext("experimental-webgl", this.config) | |
) as WebGLRenderingContext; | |
if (!webGL) { | |
throw new Error(`WebGL is unavailable.`) | |
} | |
return webGL | |
} | |
private load = () => { | |
if (this.assetManager.isLoadingComplete()) { | |
this.skeleton = this.loadSkeleton("Idle", true) | |
requestAnimationFrame(this.render) | |
} else { | |
requestAnimationFrame(this.load) | |
} | |
} | |
private loadSkeleton = (initialAnimation = "Idle", premultipliedAlpha = true) => { | |
console.debug(`Loading ${this.assetKey} anim=${initialAnimation}`) | |
// Load the texture atlas using name.atlas from the AssetManager. | |
const atlas = this.assetManager.get(this.asset.atlasUri) | |
// Create a AtlasAttachmentLoader that resolves region, mesh, boundingbox and path attachments | |
const atlasLoader = new AtlasAttachmentLoader(atlas) | |
// Create a SkeletonJson instance for parsing the .json file. | |
const skeletonJson = new SkeletonJson(atlasLoader); | |
// Set the scale to apply during parsing, parse the file, and create a new skeleton. | |
const jsonData = this.assetManager.get(this.asset.jsonUri) | |
this.debug && console.debug("JSON", jsonData) | |
const skeletonData = skeletonJson.readSkeletonData(jsonData); | |
const skeleton = new Skeleton(skeletonData) | |
let skin = this.asset.skin | |
const allSkins = skeletonData.skins || [] | |
const allSkinNames = allSkins.map(skin => skin.name) | |
if (!skin) { | |
skin = allSkins | |
.sort((skinA, skinB) => { | |
const scoreA = skinA.name?.toLowerCase() === SpineRenderer.DEFAULT_SKIN ? 0 : 1 | |
const scoreB = skinB.name?.toLowerCase() === SpineRenderer.DEFAULT_SKIN ? 0 : 1 | |
return scoreB - scoreA | |
}) | |
.at(0)?.name || SpineRenderer.DEFAULT_SKIN | |
console.debug(`skin guess=${skin}, available=${allSkinNames.join(", ")}`) | |
} else { | |
console.debug(`skin=${skin}, available=${allSkinNames.join(", ")}`) | |
} | |
skeleton.setSkinByName(skin); | |
const bounds = this.calculateBounds(skeleton); | |
// Create an AnimationState, and set the initial animation in looping mode. | |
const animationStateData = new AnimationStateData(skeleton.data); | |
const animationState = new AnimationState(animationStateData); | |
animationState.setAnimation(0, initialAnimation, true); | |
if (this.debug) { | |
animationState.addListener({ | |
start: function (track) { | |
console.log("Animation on track " + track.trackIndex + " started"); | |
}, | |
interrupt: function (track) { | |
console.log("Animation on track " + track.trackIndex + " interrupted"); | |
}, | |
end: function (track) { | |
console.log("Animation on track " + track.trackIndex + " ended"); | |
}, | |
dispose: function (track) { | |
console.log("Animation on track " + track.trackIndex + " disposed"); | |
}, | |
complete: function (track) { | |
console.log("Animation on track " + track.trackIndex + " completed"); | |
}, | |
event: function (track, event) { | |
console.log("Event on track " + track.trackIndex + ": " + JSON.stringify(event)); | |
} | |
}) | |
} | |
// Pack everything up and return to caller. | |
return { skeleton: skeleton, state: animationState, bounds: bounds, premultipliedAlpha: premultipliedAlpha }; | |
} | |
private calculateBounds = (skeleton: Skeleton) => { | |
skeleton.setToSetupPose(); | |
skeleton.updateWorldTransform(); | |
const offset = new Vector2(); | |
const size = new Vector2(); | |
skeleton.getBounds(offset, size, []); | |
return { offset: offset, size: size }; | |
} | |
private lastFrameTime = Date.now() / 1000; | |
private render = () => { | |
const now = Date.now() / 1000; | |
const delta = now - this.lastFrameTime; | |
this.lastFrameTime = now; | |
// Update the MVP matrix to adjust for canvas size changes | |
this.resize(); | |
const gl = this.webGL.gl; | |
gl.clearColor(0.3, 0.3, 0.3, 1); | |
gl.clear(gl.COLOR_BUFFER_BIT); | |
// Apply the animation state based on the delta time. | |
const state = this.skeleton.state; | |
const skeleton = this.skeleton.skeleton; | |
const bounds = this.skeleton.bounds; | |
const premultipliedAlpha = this.skeleton.premultipliedAlpha; | |
state.update(delta); | |
state.apply(skeleton); | |
skeleton.updateWorldTransform(); | |
// Bind the shader and set the texture and model-view-projection matrix. | |
this.shader.bind(); | |
this.shader.setUniformi(Shader.SAMPLER, 0); | |
this.shader.setUniform4x4f(Shader.MVP_MATRIX, this.mvp.values); | |
// Start the batch and tell the SkeletonRenderer to render the active skeleton. | |
this.batcher.begin(this.shader); | |
this.skeletonRenderer.premultipliedAlpha = premultipliedAlpha; | |
this.skeletonRenderer.draw(this.batcher, skeleton); | |
this.batcher.end(); | |
this.shader.unbind(); | |
// draw debug information | |
// var debug = $('#debug').is(':checked'); | |
// if (debug) { | |
// debugShader.bind(); | |
// debugShader.setUniform4x4f(spine.webgl.Shader.MVP_MATRIX, mvp.values); | |
// debugRenderer.premultipliedAlpha = premultipliedAlpha; | |
// shapes.begin(debugShader); | |
// debugRenderer.draw(shapes, skeleton); | |
// shapes.end(); | |
// debugShader.unbind(); | |
// } | |
requestAnimationFrame(this.render); | |
} | |
private resize = () => { | |
var w = this.canvas.clientWidth; | |
var h = this.canvas.clientHeight; | |
var bounds = this.skeleton.bounds; | |
if (this.canvas.width != w || this.canvas.height != h) { | |
this.canvas.width = w; | |
this.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 / this.canvas.width; | |
var scaleY = bounds.size.y / this.canvas.height; | |
var scale = Math.max(scaleX, scaleY) * 1.2; | |
if (scale < 1) scale = 1; | |
var width = this.canvas.width * scale; | |
var height = this.canvas.height * scale; | |
this.mvp.ortho2d(centerX - width / 2, centerY - height / 2, width, height); | |
this.webGL.gl.viewport(0, 0, this.canvas.width, this.canvas.height); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment