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 / MeshRenderer 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.

MeshRenderer 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(MeshRenderer, {
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 MeshRenderers 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 MeshRenderer 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-bitmap — RenderTexture 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).

A whole scene goes into one by pointing a camera at it: set Camera.renderTarget to the target’s handle and that camera draws there instead of the screen, before the screen cameras and without competing to be the main view. Its own sorting layers (Camera.cullingMask) decide what it sees, so a minimap camera and the player’s camera can look at the same world and show different parts of it. On WebGL2 today; the WebGPU backend cannot yet hand a render target’s texture to a Sprite:

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.