Skip to content

Instantly share code, notes, and snippets.

@nwellis
Last active February 25, 2023 14:59
Show Gist options
  • Save nwellis/30f5cc581f94c6fde062df6a8153790c to your computer and use it in GitHub Desktop.
Save nwellis/30f5cc581f94c6fde062df6a8153790c to your computer and use it in GitHub Desktop.
Renders a spine in an HTML canvas element with WebGL enabled
---
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}
/>
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