npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@driftengine/ui2d

v4.13.6

Published

The 2D layer: batched sprites, sheets, tilemaps, and a retained interface tree over them

Downloads

8,548

Readme

@driftengine/ui2d

The 2D layer: quads with a texture on them, batched, in the order you drew them.

Cost: 10.5 KB gzipped on top of core. Measured by scripts/size-gate.test.mjs, which fails if it drifts more than 3% — the number is derived from the same floors that gate asserts, so a README quoting a stale one is a red suite rather than a thing somebody notices.

What it is

import { createAffine2D, createSpritePass, drawSprite, screenToNdc } from '@driftengine/ui2d';

const sprites = createSpritePass({ capacity: 4096 });
sprites.setTexture(0, await createImageBitmap(sheetImage));
const handle = renderer.registerPass(sprites);

const affine = createAffine2D();

function frame(): void {
  sprites.reset();
  sprites.setTransform(screenToNdc(canvas.clientWidth, canvas.clientHeight, affine));
  drawSprite(sprites.batch, 0, { x: 24, y: 24, w: 64, h: 64 }, null, null);

  renderer.beginFrame(clear);
  renderer.bindMeshPass(camera, env);
  renderer.drawMesh(ground, model);
  renderer.drawPass(handle); // wherever in the frame you want the 2D layer
  renderer.endFrame();
}

It draws through registerPass, not through a verb on the renderer. That is what lets it be a package at all: the renderer's verb surface is fixed and nothing outside render/ can add to it, so this owns its own program on both backends and the caller decides where in the frame it lands. pass.ts in core names this package as one of the three that mechanism exists for.

Two coordinate systems, and they count y opposite ways

screenToNdc(width, height, out) is CSS pixels from the top-left — the convention InsetRect and fillPanel already use, and the one a caller laying out an overlay already has.

worldToNdc(camera, width, height, out) is a 2D world seen through a camera, and there y counts up. A caller placing a platform above a floor should not have to subtract, and a 2D world is a world. The two are different enough that sharing one convention would make every caller wrong half the time, so they are named separately and each says which it is.

A Camera2D is where it is, how far in (zoom, in pixels per world unit) and which way up (rotation, radians, anticlockwise). Turning the camera turns the world the other way.

A picture keeps its top up in both. A frame's top row is drawn at the top of the sprite whichever way y counts, because the vertex stage reads the affine's orientation and turns the frame's rows to match. It did not until 2026-10-02: every sprite and tile drawn through worldToNdc came out upside down, and scripts/sprite-check.mjs, which checked screen space only, passed. It draws the same rectangles in a world now and asserts the frame is byte for byte the one screen space draws.

Order is the layering

There is no depth here, on purpose: a sprite writes no depth and tests none, so the order sprites were submitted in is the order they are drawn. That is the same decision the rest of the engine takes about pass ordering — ROADMAP.md records gate 1.2 withdrawing dependency ordering because the API is immediate-mode and the caller's draw order is already the semantic one.

One batch, many textures, runs between them

drawSprite names a texture slot. Consecutive sprites on the same slot are one run and one draw; a slot change starts a new run, and going back to the first slot starts a third rather than regrouping — regrouping would reorder the picture.

This is the shape AGENTS.md asks for: the number to hold down is material changes, not draws. A tilemap over one sheet is one run however many thousand tiles it is.

A batch has a fixed capacity and does not grow. Past it, drawSprite counts into batch.dropped rather than allocating under a frame — a caller drawing more than it planned for wants counting, not absorbing.

Sheets

A sheet is the rectangles of one texture that each hold a picture. gridSheet cuts it into equal cells, row-major; namedSheet takes whatever rectangles a packer put where. Both answer a frame by index, and frameOf turns a name into one.

const sheet = gridSheet(0, 256, 128, 32, 32); // slot 0, 8 x 4 cells
const frame = createSpriteFrame(); // reused; reading one allocates nothing
drawSprite(batch, sheet.texture, place, sheetFrame(sheet, frameOf(sheet, '5'), frame), null);

A cell that would run off the edge is not emitted. A sheet 70 texels wide cut into sixteens has four whole cells and six texels of margin, and a fifth cell reading into that margin is a frame with a stripe of nothing down one side — which reads as a rendering bug rather than as a badly measured atlas.

A frame index the sheet does not have reads as the whole texture rather than throwing, because this is called per sprite per frame and nothing may throw in the frame loop. What a caller then sees is the entire atlas drawn where one picture should be, which is unmistakable.

Tilemaps

const map = createTilemap(1000, 1000, 32, 32);
setTile(map, 4, 7, frameOf(sheet, 'grass'));
const drawn = drawTilemap(batch, map, sheet, { x: camX, y: camY, w: 1280, h: 720 }, null);

The cost is the view rather than the map. The visible span is arithmetic on four numbers, so a million-cell map costs the few hundred cells on screen — measured at 25 drawn from a 1,000 by 1,000 map. A walk over every cell testing each against the view draws exactly the same picture and is unusable at the size a tilemap exists for.

Which way the rows run is the affine's business, not the map's. A cell's corner is y + row * tileHeight, so in screen space — where y counts down — row 0 is the top row, and in a 2D world it is the bottom. That is the same rule SpritePlacement states about its own corner, and one rule rather than a flag is what keeps the two agreeing.

Textures

setTexture(slot, source, options) takes an ImageBitmap, an HTMLCanvasElement or an OffscreenCanvas — narrower than TexImageSource because that is the union both backends take without a conversion. An <img> reaches it through one createImageBitmap, which is also where the decode's own orientation and premultiply options live.

colorSpace defaults to srgb, which is right for anything painted to be looked at; the sampler decodes and the pass's own output transform re-encodes, so the blend happens in linear light. filter defaults to nearest, because a sheet is usually pixel art and linear filtering is the thing that makes pixel art look wrong.

Type in an atlas wants mipmap: true

mipmap defaults to false, which is right for pixel art: a chain is memory nothing samples, and at a distance it dissolves art whose whole point is the pixel. A glyph page is the sheet where that default is wrong. Bake one page at 96 px, draw body copy at 11, and that is a 7x minification: linear reads four texels out of a footprint covering dozens, so a t crossbar two texels tall lands on about a quarter of a pixel and survives or not depending on where the sample falls. A player reported it as Step-In Uppercut reading Slep-In Uppercul, with the same letter surviving in one word and not the next, so the line looked unevenly spaced as well as misread.

sprites.setTexture(0, glyphPage, { filter: 'linear', mipmap: true });

Measured on demo/dev/glyphMip.ts, twelve copies of one glyph at different subpixel offsets: the ink varies between copies with a standard deviation of 0.0130 plain and 0.0020 mipmapped, so 6.6x less copy-to-copy disagreement, identical on both backends. scripts/glyph-mip-check.mjs is that measurement as a gate.

With filter: 'linear' this is trilinear. With filter: 'nearest' the levels are still blended, because that is minification and filter is about magnification.

Pad the cells yourself. A lower level mixes texels the packer put next to each other, so a sheet whose frames touch shows its neighbours once the chain is deep enough to reach them. How much padding depends on how far down the sheet is ever sampled, which is a fact about the sheet rather than about the sampler — the engine cannot size it for you.

Neither backend flips the image. surfaceTexture.ts in core carries the whole argument and the bug it came from: WebGL2 ignores UNPACK_FLIP_Y_WEBGL for an ImageBitmap and honours it for a canvas, so a pipeline checked with one source type is half checked. scripts/sprite-check.mjs checks both.

The retained interface tree

const root = createUiNode({ direction: 'column', width: 'grow', height: 'grow', justify: 'end' });
const bar = addUiChild(root, createUiNode({ direction: 'row', padding: 10, gap: 10, name: 'bar' }));
addUiChild(bar, createUiNode({ width: 60, height: 40, interactive: true, name: 'start' }));

layoutUiTree(root, 0, 0, canvas.clientWidth, canvas.clientHeight);
routeUiPointer(input, root, pointerX, pointerY, pointerDown);
drawUiTree(sprites.batch, root, sprites.white, null);

layoutUiTree is two passes and no third: measure what every node comes to on its own, bottom-up, then place what got a size, top-down. A fit that depended on the space it was given would need a rule for when to stop, which is where a layout engine stops being explicable.

Three size cases — a number, fit, grow — rather than percentages or flex weights. A percentage of a parent that is itself fit is a cycle. A grow child measures as fit, which is what lets a menu sized to its contents hold a row that fills it.

It allocates nothing. Every number lands in a UiRect the node already owns, which is why a node is an object with mutable fields rather than a description that gets resolved into one: an interface tree is tens of nodes built once and mutated, not thousands rebuilt per frame.

The box you give it is what is available — the root resolves its own size against it, so the same call lays out a full-screen HUD and a tooltip.

Clipping and scrolling

A node made with clip: true bounds what its descendants draw and where they can be pointed at. drawUiTree cuts every quad under it to the rectangle its clipping ancestors leave, an image's frame in proportion, and hands the sink that visible rectangle so a caller can cut its text the same way; uiHitTest finds nothing outside it. A clipping node's scrollX and scrollY move its children under it at layout, and routeScrollWheel, scrollBy and clampScroll move them within scrollExtent. Until 2026-10-02 the clip was laid out and scrolled and nothing drew it: a list's rows drew wherever they had scrolled to, and a row scrolled below the list still took the pointer there. It is cut on the CPU rather than by a scissor, which is state a contributed pass would have to set and restore on both backends.

Hit testing, focus and routing

uiHitTest searches last-drawn first, so it agrees with the picture. A node that is not interactive does not block what is behind it: a backdrop that swallowed clicks makes every button under a plate dead, which no screenshot shows. A modal that wants to swallow them says so by being interactive.

routeUiPointer activates on a press and a release on the same node, so somebody who pressed the wrong button can slide off it and let go. It sets hovered and pressed on the nodes themselves, so a caller paints a button from its own state.

routeUiKey handles three keys — Tab both ways, Enter and space — and reports everything else unhandled, so a caller can have it. Focus order is tree order rather than a declared index, and a node hidden since it was focused does not take the keyboard with it.

Text is the caller's. This package draws quads; core already draws two kinds of text. Pass a UiContentSink to drawUiTree and it hands you every node with text at its resolved rect, in the order it was drawn, with the part of it a clip leaves visible.

From DriftScript

drift/ui is bound: eighteen capabilities over the tree, addressed by the names its nodes were built with. A host builds the tree and hands the root to a script through uses, the way a Terrain arrives.

import { layout, hovered, show } from "drift/ui"

fn menu(tree: UiTree) {
    ui.layout(tree, 0, 0, 1280, 720)
    ui.show(tree, "start", ui.hovered(tree, "start"))
}

Reading a laid-out box is scene.read and inside the fixed step; hovered and pressed are input.read and outside it, so a @deterministic system cannot ask where the pointer is.

drift/2d is bound too, and it is why DriftScript has import aliases. A module's namespace was the last segment of its path, so a call would have been 2d.sprite(...) — a number followed by an identifier the lexer refuses — and the binding was written, wired, found uncallable and withdrawn rather than shipped. DriftScript 1.11.0 answered it at the import, so the namespace is written out:

import { sprite, tilemap } from "drift/2d" as sprites

fn hud(batch: SpriteBatch) {
    sprites.sprite(batch, 0, 10, 10, 32, 32)
}

Twelve capabilities over a batch, a sheet and a tilemap. A draw is scene.write and outside the fixed step; reading a sheet or a cell is scene.read and inside it, so a @deterministic system may ask what is in a cell and may not draw it. dropped is exposed for the same reason it exists: a batch that overflows drops draws, and a script with no way to read the count finds out by noticing something missing from a corner of the screen.

What it is checked by

scripts/sprite-check.mjs, on a real GPU, on both backends. Every sheet in it is two texels by two with four different colours, so a sprite's four quadrants say which corner is which — an assertion a mirror fails and a bounding box cannot, since a mirror preserves every count and every box. The control is submission order: the same two sprites are drawn both ways round and the overlap changes colour.

It found a real defect on its first run. A contributed pass inherits whatever cull state the last scene draw left on, and a screen-space quad's winding is not the scene's — so WebGL2 drew nothing at all while WebGPU, whose pipeline states its own cull mode, was pixel-perfect. No error on either side.

scripts/ui-check.mjs does the same for the tree, and it asserts the one thing a unit test cannot: the page publishes the boxes the layout computed and the bounding boxes of the colours they were drawn in, and the two must agree. A bar sized by its own contents lands at 530, 660, 220 by 60 on both backends, to 0.0 px, with the frame byte-identical in all three pointer states. The control is the pointer: the middle button paints itself from its own hovered and pressed flags, so routing is a colour in the frame rather than the router's own return value read back.