Skip to content

Sprites & Rendering

The Sprite is the workhorse 2D renderer: an entity with a Sprite component draws a textured quad in the world. For solid primitives without a texture there’s ShapeRenderer. Everything is drawn through a Camera, and draw order is controlled by sorting layers.

A sprite needs a Transform (where it is) and a Sprite (what it draws). Load a texture through the Assets resource and put its handle on the sprite:

import { defineSystem, Commands, Res, Query, Mut, Transform, Sprite, Assets } from 'esengine';
const spawnPlayer = defineSystem([Commands(), Res(Assets)], async (cmds, assets) => {
const tex = await assets.loadTexture('assets/textures/player.png'); // { handle, width, height }
cmds.spawn()
.insert(Transform, { position: { x: 0, y: 0, z: 0 } })
.insert(Sprite, { texture: tex.handle, size: { x: tex.width, y: tex.height } });
});

sprite.texture is a texture handle, not a path — you get one from a loader (assets.loadTexture(ref)). In the editor you don’t write this: drag an image from the Content Browser onto an entity, or set the Sprite’s texture field in Details.

Field Default Description
texture 0 Texture handle from a loader (0 = untextured, draws a white quad).
color {1,1,1,1} Tint multiplied into the texture (white = unchanged).
size {100,100} Rendered size in world units.
pivot {0.5,0.5} Anchor point the sprite rotates and scales about, as a fraction of its size. Values outside 0–1 sit off the sprite.
layer 0 Sorting layer — controls draw order (see below).
flipX / flipY false Mirror horizontally / vertically.
material 0 A custom material handle (0 = the default sprite shader).
lit false Receive 2D lights with a flat normal — the one-flag path to lit sprites (a custom material overrides it).
uvOffset / uvScale {0,0} / {1,1} Sub-rectangle of the texture to show (for atlases / scrolling).
tileSize / tileSpacing {0,0} Repeat the texture across the sprite (0 = no tiling).
parallax {1,1} Parallax scroll factor (see below).
enabled true Hide without removing the component.

color is an RGBA multiplier over the texture. White ({1,1,1,1}) draws it unchanged; tint it, or fade it by lowering alpha:

sprite.color = { r: 1, g: 0.4, b: 0.4, a: 1 }; // reddened
sprite.color = { r: 1, g: 1, b: 1, a: 0.5 }; // 50% transparent

color (and size) are animatable — key them in the Sequencer for flashes and fades.

size is the quad’s size in world units, independent of the texture’s pixel dimensions — set it to { tex.width, tex.height } for 1:1, or anything else to scale. Scale on the Transform multiplies on top of it. pivot is the normalized anchor (0–1) the sprite rotates and scales about: {0.5, 0.5} is centered, {0, 0} is the bottom-left corner, {0.5, 0} pins the bottom-center (handy for characters standing on the ground).

The same sprite rotated about three different pivots — the pivot dot stays fixed while the quad swings around it: center spins in place, a corner swings out, bottom-center pins the feet

A pivot outside 0–1 is legal and useful: {0.5, -1} puts the turning point a full sprite-height below the art, which is how you hang a swinging arm or a door off a hinge it doesn’t contain.

Storing a fraction is what lets a pivot survive a resize, but it isn’t how anyone thinks about one — see In the editor for entering it in pixels, picking it from the nine common positions, or dragging it on the sprite itself.

World units are design pixels. Dropping a texture into the viewport spawns a sprite at the texture’s pixel dimensions (size = { tex.width, tex.height }), and the editor’s cameras are authored so one world unit is one design pixel (orthoSize = designHeight / 2). So Sprite.size is in design pixels, and pivot is a normalized fraction of that size. The world is Y-up — positive Y is up on screen — which is why pivot: {0, 0} is the bottom-left corner, not the top-left.

When sprites overlap, draw order decides what’s on top. Order is set by the sorting layer (sprite.layer) — a named layer list you define in Project Settings → Rendering, so the inspector shows a dropdown of Background, Default, Foreground, etc. instead of a raw number. Lower layers draw first (behind).

Left: three sorting layers stacked so a higher layer draws in front. Right: on a Y-sorted layer a sprite lower on screen draws in front of one higher up.

Naming is a readability feature, not a limit: name up to thirty-two slots (a slot’s index is its z-order). The names are suggestions — type a number the list doesn’t offer and the field takes it, and with no names at all layer is a plain number field. The renderer sorts on any integer either way.

sprite.layer = 0; // e.g. "Background"
sprite.layer = 2; // e.g. "Foreground" — drawn on top

Same layer, same z? Then scene order decides: the engine draws entities in the order the scene lists them, so the one further down the Outliner lands on top. Dragging a row in the Outliner therefore changes what covers what — and the viewport updates as you drop it, exactly as the running game will draw it. A dragged parent takes its children along, so a child always keeps its position relative to its parent.

At runtime the same order is available without touching layers — useful for “bring this card to the front”:

// Draw `card` last (on top) among these three, leaving everything else alone.
world.applyEntityOrder([other, another, card]);

For top-down games, check a layer under Project Settings → Rendering → Y-sorted layers and every sprite, shape, and text on that layer draws in world-Y order: entities lower on screen draw on top, so a character walking below a tree appears in front of it — no manual layer or z juggling.

Check a layer under Project Settings → Rendering → Depth-sorted layers and it stops using paint order entirely: what covers what is decided by the depth buffer, from each entity’s Transform z. With a perspective camera that is 2.5D — sprites at different depths occlude each other correctly from any angle, with no sort order to maintain by hand.

Which sprites take part is decided by their material’s blend mode, because that is what says whether a draw reads what is already on screen:

Blend mode In a depth layer
None (opaque) Writes depth and is occluded by anything nearer, whatever order it was drawn in.
Anything else (translucent) Tests against depth but never writes it, and stays in back-to-front paint order.

That split is not a setting — a translucent sprite that wrote depth would clip the sprites behind it into hard black edges. So a sprite you want to occlude properly needs an opaque material; leave the blend mode alone and it behaves as it always has, just with things in front of it hiding it.

To look at any of this while authoring, switch the viewport’s 2D / 3D button (scene toolbar) — the editor’s own eye becomes a perspective one. It changes only your view: the Game view always shows the scene’s camera. Picking and dragging follow, so a sprite at z = -400 is grabbable where it is drawn.

  • Flip a sprite with flipX / flipY — e.g. face a character left or right without a separate texture.
  • Atlases: show one cell of a packed sheet by setting uvOffset and uvScale to the cell’s normalized rectangle. (For frame-by-frame animation, drive these from the Animator instead of by hand.)
  • Scrolling: animate uvOffset over time to scroll a texture (water, conveyor belts), and set tileSize to repeat it across a large sprite.

How a texture is sampled is set per texture handle, not per sprite:

The same 16×16 texture magnified: Nearest keeps hard pixel edges, Linear blends them into a smooth gradient

The same tiny texture magnified ~15×. Nearest keeps texel edges hard (pixel art); Linear interpolates between texels for a smooth look (the default).

Enum Values Meaning
TextureFilter Nearest, Linear Nearest = hard texel edges (pixel art); Linear = smooth interpolation (default).
TextureWrap Repeat, ClampToEdge, MirroredRepeat What UVs outside 0–1 sample — tiling repeats, clamp stretches the edge pixel.
import { TextureFilter, TextureWrap, setTextureFilter, setTextureWrap, setTextureParams } from 'esengine';
setTextureFilter(tex.handle, TextureFilter.Nearest); // crisp pixel art
setTextureWrap(tex.handle, TextureWrap.Repeat); // tile outside 0–1 UVs
// The two helpers above each reset the *other* params to their defaults
// (ClampToEdge / Linear). To set filter and wrap together, use the full form:
setTextureParams(tex.handle, TextureFilter.Nearest, TextureFilter.Nearest,
TextureWrap.Repeat, TextureWrap.Repeat);

setTextureParams(textureId, minFilter, magFilter, wrapS, wrapT) is the complete surface — separate minification/magnification filters and per-axis wrap. Repeat is what makes tileSize tiling and UV-scroll effects seamless.

How each wrap mode samples UVs outside 0–1: Repeat tiles the texture, Clamp to edge extends the border pixel, Mirror flips every other tile

What each wrap mode does with UVs outside the 0–1 range (centre tile = the texture): Repeat tiles it, Clamp to edge stretches the border pixel, and Mirror flips every other copy.

parallax scales how much a sprite moves relative to the camera: 1 moves with the world, 0 locks it to the camera (a fixed backdrop), and values in between scroll slower for depth. Layer a few sprites at 0.2, 0.5, 0.8 for a classic parallax background.

Assign sprite.material to draw the sprite with your own shader — a tint, a dissolve, a distortion — see Materials & Shaders.

To make a sprite respond to Light2D lights, the simplest path is to set sprite.lit = true — no custom material needed (it’s lit with a flat normal). For normal maps or a fully custom look, use a Lit-2D material instead. Either way, see 2D Lighting & Shadows.

Sprite filters (outline, glow, drop shadow)

Section titled “Sprite filters (outline, glow, drop shadow)”

For the most common per-sprite effects you don’t need to write a shader — SpriteFilter builds a ready-made material you assign to sprite.material:

import { SpriteFilter } from 'esengine';
sprite.material = SpriteFilter.createOutline({ color: { r: 1, g: 1, b: 1, a: 1 }, width: 2 });
sprite.material = SpriteFilter.createGlow(); // warm outline preset
sprite.material = SpriteFilter.createDropShadow({ offsetX: 3, offsetY: 3, blur: 2 });

Tweak a live filter with setOutlineColor / setOutlineWidth / setShadowOffset / setShadowBlur (selection pulses, hit flashes). Each call creates a material — share one handle across sprites that use the same look, and pass texelSize: { x: 1/texWidth, y: 1/texHeight } for exact pixel widths on textures that aren’t 512×512.

For solid circles, capsules, and rounded rectangles — placeholders, bars, debug overlays — use ShapeRenderer instead of a texture (ShapeType.Circle, Capsule, or RoundedRect):

import { ShapeRenderer, ShapeType } from 'esengine';
cmds.spawn()
.insert(Transform, { position: { x: 0, y: 0, z: 0 } })
.insert(ShapeRenderer, {
shapeType: ShapeType.RoundedRect,
size: { x: 200, y: 80 },
cornerRadius: 12,
color: { r: 0.2, g: 0.6, b: 1, a: 1 },
});

ShapeRenderer shares the sorting layer and color conventions with Sprite.

TrailRenderer draws a world-space ribbon along an entity’s recent path — sword swipes, dashes, projectile streaks. Add the component and move the entity; the engine records the position history and renders the taper for you:

cmds.spawn()
.insert(Transform, { position: { x: 0, y: 0, z: 0 } })
.insert(TrailRenderer, { time: 0.4, startWidth: 24, blendMode: 1 }); // additive glow
Field Default Description
time 0.5 Seconds each trail point lives before fading out of the tail.
minVertexDistance 5 Min world distance moved before a new point is recorded (0 = every frame).
emitting true false freezes emission — the streak detaches and fades in place.
startWidth / endWidth 20 / 0 Ribbon width at the head / tail (0 tapers to a line).
startColor / endColor white / white α 0 Color lerped head → tail; tail alpha 0 is the fade-out.
texture 0 Texture along the ribbon (U runs head→tail); 0 = vertex colors only.
blendMode 0 0 Normal, 1 Additive (glow), 2 Multiply.
layer / material 0 / 0 The usual sorting layer / custom material.

The Trail resource (Res(Trail)) adds clear(entity) to drop an entity’s history instantly — e.g. when teleporting, so the trail doesn’t streak across the map.

BitmapText renders world-space text from a prebaked bitmap font — damage numbers, name tags, retro UI. Point font at a BMFont asset (.fnt text or .bmfont JSON) and set text:

Field Default Description
text '' The string to draw.
font 0 The bitmap-font asset (.fnt / .bmfont).
fontSize 1 Scale over the font’s authored size.
align Left Left / Center / Right.
spacing 0 Extra per-character spacing.
color {1,1,1,1} Tint — animatable (flash a damage number).
layer / parallax 0 / {1,1} Same sorting layer / parallax semantics as Sprite.

For screen-space UI text (layout, wrapping, SDF sharpness), use the UI Text widget instead — see UI.

“Did the click land on this sprite?” is two steps: convert the screen point to world space, then test it against the sprite’s rectangle. The CameraView resource does the first (see Camera); for the second the SDK exports pivot-aware point tests:

Function Tests
pointInWorldRect(px, py, worldX, worldY, worldW, worldH, pivotX, pivotY) An axis-aligned rectangle placed by position + pivot (a sprite’s exact footprint when unrotated).
pointInOBB(…, rotationZ, rotationW) The same rectangle rotated — pass the transform quaternion’s z/w; falls back to the rect test when unrotated.
pointInHitArea(px, py, area) A custom HitAreaShaperect, circle, or polygon.
import { defineSystem, Query, Res, CameraView, Input, Transform, Sprite, pointInOBB } from 'esengine';
const clickSprites = defineSystem(
[Query(Transform, Sprite), Res(CameraView), Res(Input)],
(q, view, input) => {
if (!input.isMouseButtonPressed(0)) return;
const p = view.screenToWorld(input.mouseX, input.mouseY);
if (!p) return;
for (const [entity, t, s] of q) {
const w = s.size.x * t.worldScale.x, h = s.size.y * t.worldScale.y;
if (pointInOBB(p.x, p.y, t.worldPosition.x, t.worldPosition.y,
w, h, s.pivot.x, s.pivot.y,
t.worldRotation.z, t.worldRotation.w)) {
console.log('hit', entity);
}
}
});

For sprites whose clickable region isn’t their rectangle — an irregular character, a donut-shaped button — describe the region as a HitAreaShape in sprite-local coordinates and test the localized point:

import { pointInHitArea, type HitAreaShape } from 'esengine';
const hull: HitAreaShape = { type: 'polygon', points: [0, 0, 64, 0, 64, 48, 32, 64, 0, 48] };
// For an unrotated sprite, localize by subtracting its origin:
const hit = pointInHitArea(p.x - t.worldPosition.x, p.y - t.worldPosition.y, hull);

screenToWorld is also exported standalone (it takes an inverse view-projection matrix and a viewport rectangle) for code that manages its own camera math — the CameraView resource is the same function with the active camera filled in. For interactive UI, don’t hand-roll any of this: Interactable + UIEvents do picking for you (see UI).

CacheAsBitmap supports rendering an entity’s expensive content once into an offscreen texture and drawing that single quad afterwards — the classic cache-as-bitmap trade: GPU memory (width × height RGBA) for per-frame draw cost. It pays when content is costly to draw every frame (many vector paths, lots of text or shapes) but rarely changes; it does nothing for you if the content changes every frame — you’d re-render the cache each time and pay the extra blit.

Field Default Description
enabled true Whether caching is active for this entity.
dirty true Set true to request a re-render of the cache.
width / height 256 Cache texture size in pixels.

The component is the bookkeeping half — the SDK does not ship a system that walks the scene and caches subtrees automatically. You drive the refresh with the exported helpers: a per-entity cache store (getCacheForEntity, setCacheForEntity, removeCacheForEntity, clearAllCaches) and CacheBitmap, a thin render-target wrapper (create, beginDraw/endDraw, resize, release):

import { CacheBitmap, getCacheForEntity, setCacheForEntity, Draw } from 'esengine';
// Refresh (only when dirty):
let cache = getCacheForEntity(entity);
if (!cache) {
cache = CacheBitmap.create(512, 256);
setCacheForEntity(entity, cache);
}
CacheBitmap.beginDraw(cache, viewProjection); // an ortho matrix framing the content
// … draw the expensive content once, with Draw / Graphics …
CacheBitmap.endDraw();
// Every frame thereafter: one textured quad instead of the whole redraw.
Draw.texture(pos, { x: 512, y: 256 }, cache.textureId);

cache.textureId is an ordinary texture handle — it also works as a Sprite.texture. Under the hood this is a RenderTexture without a depth buffer.

  • Create → Sprite adds a sprite entity, or drag an image from the Content Browser into the viewport to spawn one already textured.
  • Every field above is editable in Details; sprites, cameras, and lights show as gizmos in the viewport. See The Editor.
  • pivot is authored three ways, all writing the same stored fraction:
    • The 3×3 grid under the field picks the nine common positions in one click.
    • The frac / px switch on the row enters it in pixels instead — px shows pivot × size, and typing 32 stores 32 / size. The unit is a view; the scene always holds the fraction.
    • With the pointer tool, a single selected sprite gets a pivot dot in the viewport. Dragging it moves the pivot through the artwork without moving the artwork — the transform takes up the slack — so you can put the turning point on a foot or a hinge by eye. It hides under Move/Rotate/Scale, whose centre grab sits in the same place.
  • Textures — the image itself: formats, import settings, and how to read its width and height.
  • Animation — sprite-sheet flipbooks and tweened properties.
  • Custom Drawing — meshes and immediate-mode shapes beyond sprites.
  • 2D Lighting & Shadows — normal maps and per-sprite lighting.