Guide

Guide Rendering Sprites

Sprites

Render image-based content from textures, sheets, SVG, and video.

Intro ~4 min read

What you'll learn

  • render textures, sheets, SVG, and video as sprites
  • control anchor, blend mode, and frames

Before you start

Sprites

A Sprite is the primary drawable for textured quads — rectangles that display an image, a sub-region of an image, or a rendered-to-texture surface. Most visible content in a 2D scene passes through sprites.

From texture to screen

A sprite needs a texture. The shortest path from file to visible quad:

import { Scene, Sprite, Texture } from '@codexo/exojs';

class MyScene extends Scene {
    async load(loader) {
        await loader.load(Texture, { hero: 'image/hero.png' });
    }

    init(loader) {
        this.hero = new Sprite(loader.get(Texture, 'hero'));
        this.addChild(this.hero);
    }

    draw(context) {
        context.backend.clear();
        context.render(this.root);
    }
}

The sprite’s rendered size defaults to the texture’s pixel dimensions. The sprite draws a quad from (0, 0) to (texture.width, texture.height) in local space.

Positioning and anchor

A sprite’s position places its anchor point in the parent’s coordinate space. By default the anchor is (0, 0) — the top-left corner — so setPosition(400, 300) places the top-left of the sprite at world position (400, 300).

setAnchor(0.5) centers the anchor at the sprite’s middle, making setPosition(400, 300) place the sprite’s center at (400, 300):

init(loader) {
    const { width, height } = this.app.canvas;

    this.hero = new Sprite(loader.get(Texture, 'hero'));
    this.hero.setAnchor(0.5);
    this.hero.setPosition(width / 2, height / 2);
    this.addChild(this.hero);
}

setAnchor(x, y) accepts two arguments for separate horizontal and vertical anchors. A value of 0 is left/top, 0.5 is center, 1 is right/bottom.

Size and scale

sprite.width and sprite.height report the rendered pixel dimensions (texture frame size multiplied by absolute scale). Setting them adjusts the scale to match:

this.hero.width = 64;  // scale.x becomes 64 / textureFrame.width
this.hero.height = 64; // scale.y becomes 64 / textureFrame.height

setScale(x, y) sets raw scale factors. A scale of (2, 2) doubles the sprite’s visual size; (-1, 1) mirrors it horizontally.

Tinting

sprite.tint is a Color that multiplies the sprite’s pixel colors at draw time. White tint (Color.white) leaves the texture unchanged. A red tint mutes green and blue channels. Tinting does not allocate or create new textures:

import { Color } from '@codexo/exojs';

this.hero.tint = new Color(255, 128, 128, 1); // reddish tint
this.hero.tint = new Color(128, 128, 255, 0.8); // blue tint, 80% opacity

Texture frames

A sprite can display a sub-region of its texture through textureFrame. This is the mechanism behind spritesheet frame selection:

import { Rectangle } from '@codexo/exojs';

// Show only the top-left 32x32 pixels of a 128x128 texture
this.sprite.setTextureFrame(new Rectangle(0, 0, 32, 32));

By default setTextureFrame resets the sprite’s width and height to the frame’s dimensions. Pass false as the second argument to keep the current display size — useful for animation where frame dimensions vary but the on-screen size should stay constant:

this.sprite.setTextureFrame(new Rectangle(32, 0, 32, 32), false);

Call resetTextureFrame() to restore the full texture.

Blend modes

sprite.blendMode controls how the sprite composites with pixels already in the framebuffer. The default is BlendModes.Normal (standard alpha blending):

import { BlendModes } from '@codexo/exojs';

this.glow.blendMode = BlendModes.Additive;

Blend modes are per-sprite. Combined with tinting they cover common glow, shadow, and knock-out compositing without needing custom shaders.

BlendModes implements the full W3C compositing/blending set. Normal, Additive, Subtract, Multiply, and Screen are fixed-function GPU blends with no extra draw cost. The remaining “advanced” modes go through a backdrop-aware compositor that copies the current render target before drawing each sprite, so they carry a small per-draw backdrop-copy cost — reach for them deliberately in draw-call-heavy scenes. isAdvancedBlendMode(mode) reports which category a given mode falls into.

ModeVisual effect
NormalStandard alpha compositing — source drawn over the backdrop.
AdditiveAdds source and backdrop colors; brightens. Good for glows, light, fire, particles.
SubtractSubtracts source from backdrop; darkens toward black.
MultiplyMultiplies channel values; darkens, like stacking transparencies.
ScreenInverse of multiply; lightens, like projecting two images onto the same surface.
Darken *Keeps the darker of source and backdrop, per channel.
Lighten *Keeps the lighter of source and backdrop, per channel.
Overlay *Multiplies dark backdrop areas and screens light ones; boosts contrast.
ColorDodge *Brightens the backdrop to reflect the source color; intense highlights.
ColorBurn *Darkens the backdrop to reflect the source color; intense shadows.
HardLight *Overlay with source and backdrop roles swapped; harsh, directional lighting.
SoftLight *Softer version of Overlay; subtle, diffused lighting.
Difference *Absolute value of the channel difference; inversion / comparison effects.
Exclusion *Lower-contrast variant of Difference.
Hue *Source hue with backdrop saturation and luminosity.
Saturation *Source saturation with backdrop hue and luminosity.
Color *Source hue and saturation with backdrop luminosity; recolors while preserving shading.
Luminosity *Source luminosity with backdrop hue and saturation; inverse of Color.

* Advanced mode — routed through the backdrop-aware compositor (isAdvancedBlendMode returns true).

Spritesheets

A Spritesheet slices a single texture atlas into named frames and optional animation sequences. It accepts a JSON descriptor in the Aseprite / TexturePacker format:

import { Spritesheet } from '@codexo/exojs';

const sheet = new Spritesheet(texture, {
    frames: {
        'walk_01': { frame: { x: 0, y: 0, w: 32, h: 32 } },
        'walk_02': { frame: { x: 32, y: 0, w: 32, h: 32 } },
        'walk_03': { frame: { x: 64, y: 0, w: 32, h: 32 } },
    },
    animations: {
        walk: ['walk_01', 'walk_02', 'walk_03'],
    },
});

// Pre-built Sprite for a single frame
const sprite = sheet.sprites.get('walk_01');
this.addChild(sprite);

// Rectangle for a single frame (useful for textureFrame)
const frame = sheet.frames.get('walk_01');

The animations map feeds into AnimatedSprite.fromSpritesheet(), covered in the Animation chapter.

Non-image textures

Sprite accepts any Texture or RenderTexture. This means sprites can display video frames, SVG rasterizations, off-screen renders, or DataTexture pixel buffers — any source the engine can wrap as a Texture:

// Video as a sprite texture
import { RenderTexture, Sprite, Video } from '@codexo/exojs';

const video = loader.get(Video, 'intro');
const sprite = new Sprite(video.texture);

// A RenderTexture (offscreen render) as a sprite texture
const rt = new RenderTexture(256, 256);
// ... render something into rt ...
const offscreenSprite = new Sprite(rt);

Sprite transforms in the graph

A Sprite inherits from Drawable, which extends RenderNode. This means every sprite carries a full transform (position, rotation, scale, origin) and participates in the scene graph as a node. When a sprite is a child of a rotated Container, it rotates with the container. When it has its own filters array, those filters apply to the sprite’s rendered quad before it composites into the parent.

Collision helpers — sprite.contains(x, y) for point tests and sprite.getBounds() for AABB queries — work on the sprite’s world-space quad and account for rotation.

Examples

Sprite Basics Open in Playground View source

Preview is paused until you click Play.

import { Application, Color, Scene, Sprite, Texture } from '@codexo/exojs';
import { mountControls } from '@examples/runtime';

const app = new Application({
    canvas: {
        width: 1280,
        height: 720,
        mount: document.body,
        sizingMode: 'fit',
    },
    clearColor: new Color(18, 20, 28),
    loader: {
        basePath: 'assets/',
    },
});

class SpriteBasicsScene extends Scene {
    private ship!: Sprite;
    // A single reusable tint colour whose alpha channel we animate each frame.
    private tint = new Color(120, 200, 255, 1);
    private elapsed = 0;
    private hud!: ReturnType<typeof mountControls>;

    override async load(loader): Promise<void> {
        await loader.load(Texture, { ship: 'image/ship-a.png' });
    }

    override init(loader): void {
        const { width, height } = this.app.canvas;

        this.ship = new Sprite(loader.get(Texture, 'ship'));
        this.ship.setPosition((width / 2) | 0, (height / 2) | 0);
        this.ship.setAnchor(0.5);
        this.ship.setScale(3);
        this.ship.setTint(this.tint);

        this.hud = mountControls({
            title: 'Sprite Basics',
            hint: 'One Sprite, four transforms: position drifts, rotation spins, scale pulses, alpha fades.',
            status: 'alpha 1.00',
        });
    }

    override update(delta): void {
        this.elapsed += delta.seconds;

        const { width, height } = this.app.canvas;

        // Position: a gentle figure-eight drift around the canvas centre.
        const driftX = Math.sin(this.elapsed * 0.8) * 90;
        const driftY = Math.sin(this.elapsed * 1.6) * 50;
        this.ship.setPosition(width / 2 + driftX, height / 2 + driftY);

        // Rotation: a steady spin (degrees per second).
        this.ship.rotate(delta.seconds * 90);

        // Scale: a slow breathing pulse between 2.4x and 3.6x.
        this.ship.setScale(3 + Math.sin(this.elapsed * 1.2) * 0.6);

        // Alpha: fade the tint's alpha channel between 0.2 and 1.0 and re-apply.
        const alpha = 0.6 + Math.sin(this.elapsed * 2) * 0.4;
        this.tint.a = alpha;
        this.ship.setTint(this.tint);

        this.hud.setStatus(`alpha ${alpha.toFixed(2)}`);
    }

    override draw(context): void {
        context.backend.clear();
        context.render(this.ship);
    }
}

app.start(new SpriteBasicsScene());

A single sprite in the center of the canvas, rotating and cycling through tints.

Spritesheet Frames Open in Playground View source

Preview is paused until you click Play.

import { Application, Color, Json, Scene, Spritesheet, type SpritesheetData, Texture } from '@codexo/exojs';
import { mountControlPanel, mountControls } from '@examples/runtime';

const app = new Application({
    canvas: {
        width: 1280,
        height: 720,
        mount: document.body,
        sizingMode: 'fit',
    },
    clearColor: new Color(24, 28, 38),
    loader: {
        basePath: 'assets/',
    },
});

const CHARACTERS = ['beige', 'green', 'pink', 'purple', 'yellow'];

class SpritesheetFramesScene extends Scene {
    private spritesheet!: Spritesheet;
    private character = CHARACTERS[0];
    private frameIndex = 0;
    private fps = 8;
    private elapsed = 0;
    private playing = true;
    private hud!: ReturnType<typeof mountControls>;

    override async load(loader): Promise<void> {
        await loader.load(Texture, { characters: 'image/platformer-characters.png' });
        await loader.load(Json, { characters: 'json/platformer-characters.json' });
    }

    override init(loader): void {
        const { width, height } = this.app.canvas;
        const texture = loader.get(Texture, 'characters');
        const data = loader.get(Json, 'characters') as SpritesheetData;

        this.spritesheet = new Spritesheet(texture, data);

        // The spritesheet caches one Sprite per named frame; configure them all
        // once so any frame we draw is centred and scaled up for visibility.
        for (const sprite of this.spritesheet.sprites.values()) {
            sprite.setAnchor(0.5);
            sprite.setPosition(width / 2, height / 2);
            sprite.setScale(3);
        }

        this.hud = mountControls({
            title: 'Spritesheet Frames',
            hint: 'A two-frame walk cycle stepped on a timer from named spritesheet frames.',
        });

        const panel = mountControlPanel({ title: 'Animation' });
        panel.addSlider({ label: 'Speed (fps)', min: 1, max: 16, step: 1, value: this.fps, onChange: value => (this.fps = value) });
        panel.addCycle({
            label: 'Character',
            options: CHARACTERS,
            index: 0,
            onChange: (_, name) => {
                this.character = name;
                this.frameIndex = 0;
                this.updateHud();
            },
        });
        panel.addToggle({ label: 'Playing', value: true, onChange: on => (this.playing = on) });

        this.updateHud();
    }

    private walkFrames(): Array<string> {
        return [`character_${this.character}_walk_a`, `character_${this.character}_walk_b`];
    }

    private updateHud(): void {
        const frames = this.walkFrames();

        this.hud.setStatus(`Frame: ${frames[this.frameIndex]}  (${this.frameIndex + 1}/${frames.length})`);
    }

    override update(delta): void {
        if (!this.playing) {
            return;
        }

        this.elapsed += delta.seconds;

        const frameDuration = 1 / this.fps;

        while (this.elapsed >= frameDuration) {
            this.elapsed -= frameDuration;
            this.frameIndex = (this.frameIndex + 1) % this.walkFrames().length;
            this.updateHud();
        }
    }

    override draw(context): void {
        context.backend.clear();
        context.render(this.spritesheet.getFrameSprite(this.walkFrames()[this.frameIndex]));
    }
}

app.start(new SpritesheetFramesScene());

Navigating a spritesheet’s frames by name — the texture-frame mechanism in practice.

Where to go next

The next chapter, Text, covers GPU-accelerated text rendering — font loading, styling, layout, and using text nodes in the scene graph.