Skip to content

Custom Drawing

Most visuals are components — sprites, tilemaps, particles. When you need something they can’t express — debug overlays, procedural shapes, custom geometry — the engine exposes its drawing layer directly.

API Model Reach for it when
Draw Immediate, global — commands batch and clear every frame. Debug overlays, gizmos, anything redrawn from scratch each frame.
Graphics Retained path recorder — build once, replay through Draw with flush(). Vector shapes with paths/béziers/arcs you don’t want to re-tessellate per frame.
ShapeRenderer / Mesh2D Components — entities with sorting layers, materials, editor gizmos. Shapes/meshes that are part of the scene (see Sprites).

Rule of thumb: if the visual belongs to the scene (sorted, saved, inspectable), make it a component; if it belongs to a frame (debug, HUD-ish overlays, procedural effects), draw it in a callback.

Register a callback and it runs inside the render pass every frame, with the camera’s view-projection already active — no setup, just draw:

import { registerDrawCallback, Draw } from 'esengine';
registerDrawCallback('debug-overlay', (elapsed) => {
Draw.line({ x: 0, y: 0 }, { x: 100, y: 50 }, { r: 1, g: 0, b: 0, a: 1 }, 2);
Draw.circleOutline({ x: 0, y: 0 }, aggroRadius, { r: 1, g: 1, b: 0, a: 0.5 });
});

Callbacks draw on top of the scene; registerPreSceneDrawCallback draws under it (its callback receives { width, height, elapsed }). Remove one with unregisterDrawCallback(id). All coordinates are world-space.

Draw is an immediate-mode singleton — commands are batched automatically and cleared every frame, so call it each frame you want the shape visible:

Method Draws
line(from, to, color, thickness?) A line segment (thickness in px, default 1).
rect(pos, size, color, filled?) / rectOutline(pos, size, color, thickness?) A rectangle — pos is the center.
circle(center, radius, color, filled?, segments?) / circleOutline(…) A circle (default 32 segments).
texture(pos, size, textureHandle, tint?) A textured quad.
textureRotated(pos, size, rotation, textureHandle, tint?) Same, rotated (radians).
drawMesh(geometry, shader, transform) / drawMeshWithMaterial(geometry, material, transform?) A custom mesh by handle (see the Geometry API below).
setLayer(layer) / setDepth(depth) Sorting of subsequent commands.
setBlendMode(mode) / setDepthTest(on) Blend / depth state for subsequent commands.
getDrawCallCount() / getPrimitiveCount() Frame stats.

For shapes built from paths — polylines, béziers, arcs, rounded rects — Graphics records a path and replays it through Draw when you flush():

import { Graphics, registerDrawCallback } from 'esengine';
const g = new Graphics();
g.lineStyle(2, { r: 1, g: 1, b: 1, a: 1 });
g.beginFill({ r: 0, g: 0.5, b: 1, a: 1 });
g.drawRoundRect(-50, -25, 100, 50, 8);
g.endFill();
g.moveTo(0, 0);
g.curveTo(50, 80, 100, 0); // quadratic bézier
registerDrawCallback('hud-shape', () => g.flush());

Path methods: moveTo / lineTo, curveTo (quadratic) / cubicCurveTo, arc, drawRect / drawRoundRect / drawCircle / drawEllipse, clear(). lineStyle(thickness, color) sets the stroke; beginFill(color) / endFill() fill rects and circles (path outlines stay strokes). The path is retained — build it once, flush() it every frame.

Mesh2D is a component that renders an arbitrary indexed triangle list — procedural terrain slices, radial health arcs, water surfaces. Author the geometry on the component (positions as x,y pairs; optional per-vertex UVs and colors):

cmds.spawn()
.insert(Transform, { position: { x: 0, y: 0, z: 0 } })
.insert(Mesh2D, {
geometry: {
positions: [0, 0, 100, 0, 50, 80],
colors: [1,0,0,1, 0,1,0,1, 0,0,1,1],
indices: [0, 1, 2],
},
});

The mesh shares the standard renderable surface: texture, color, layer, lit, parallax, and material. Geometry set in a scene (or at spawn) uploads automatically; to mutate it at runtime, go through the Meshes2D resource — setGeometry(entity, geometry) re-uploads (indices are validated engine-side), getGeometry / clearGeometry round it out. Direct writes to the geometry field alone are not change-detected.

Where Mesh2D is the component path, Geometry is the immediate one: it uploads an indexed vertex buffer and hands back a handle you draw yourself — paired with a material via Draw.drawMeshWithMaterial(geometry, material, transform?) (or a raw shader via Draw.drawMesh(geometry, shader, transform)).

import { Geometry, Draw, Material, registerDrawCallback } from 'esengine';
const quad = Geometry.createQuad(120, 120); // 1 unit ≙ 1 design px
const mat = Material.create({ shader }); // a compileShader material
registerDrawCallback('custom-mesh', () => {
Draw.drawMeshWithMaterial(quad, mat); // identity transform
});
Field Default Description
vertices Interleaved vertex data as one Float32Array (max 64K floats per upload).
layout VertexAttributeDescriptor[] — one { name, type } per attribute, in buffer order.
indices Uint16Array or Uint32Array triangle list (max 16K indices per upload).
dynamic false Allocate for frequent updates via updateVertices.

DataType names each attribute’s shape: Float, Float2, Float3, Float4, Int, Int2, Int3, Int4.

Method Does
create(options) Uploads the mesh; returns a GeometryHandle (throws on failure or oversized data).
updateVertices(handle, vertices, offset?) Rewrites vertex data on a dynamic geometry (offset in floats).
release(handle) / isValid(handle) Free / probe a handle.
createQuad(width?, height?) A centered quad (defaults 1×1), layout a_position + a_texCoord.
createCircle(radius?, segments?) A triangle-fan circle (defaults 1, 32), same layout.
createPolygon(points) A fan-triangulated polygon from {x, y} points, UVs from its bounding box.

The helper meshes all use the two-attribute Float2 position + Float2 UV layout, so they pair directly with fragment-only .esshader materials.

For minimaps, portraits, mirrors — and as the backing of cache-as-bitmapRenderTexture allocates an offscreen render target. Wrap draw commands in begin/end to render into it; the resulting texture is an ordinary texture handle usable anywhere (a Sprite.texture, Draw.texture, a material texture param):

import { RenderTexture, Draw } from 'esengine';
const rt = RenderTexture.create({ width: 256, height: 256, filter: 'linear' });
RenderTexture.begin(rt, viewProjection); // an ortho view-projection you supply
Draw.circle({ x: 0, y: 0 }, 80, { r: 1, g: 0.5, b: 0, a: 1 });
RenderTexture.end();
sprite.texture = rt.texture;
Field Default Description
width / height Target size in pixels (required).
depth true Attach a depth buffer — turn off for pure 2D compositing to save memory.
filter 'nearest' Sampling filter when the result is drawn: 'nearest' or 'linear'.
Method Does
create(options) Allocates the target; returns a handle with texture (resource-table handle for components), textureId (raw device id for low-level paths), width, height.
begin(rt, viewProjection) Redirects subsequent draws into the target (you supply the matrix).
end() Ends offscreen rendering, back to the screen.
resize(rt, width, height) Releases and re-creates — returns a new handle (the texture handle changes).
getDepthTexture(rt) The depth attachment as a sampleable texture handle.
release(rt) Frees the target and its textures.

This is a low-level surface: it doesn’t render the scene for you — it redirects whatever you draw between begin/end, under a view-projection matrix you build.