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

@altpsyche/engine

v0.6.1

Published

The AltPsyche renderer and the engine above it: one door onto WebGL 2 and WebGPU, and a scene becoming a frame.

Readme

@altpsyche/engine

A renderer for WebGPU and WebGL 2, with a small scene layer above it. One import path for everything, no runtime dependencies.

You describe a frame as data: which resources exist, which pipelines run, what order the passes go in. Hand that to the renderer and it picks a backend and makes the calls. Because the frame is data, you can also ask what it will cost and whether this device can draw it, before anything reaches a driver.

npm gates dependencies: none types: included

npm install @altpsyche/engine

Draw something

This is a whole page. Paste it into a project with a bundler in front of it and it runs.

import { createSurface, glslFrame } from '@altpsyche/engine';

const canvas = document.createElement('canvas');
canvas.style.cssText = 'display:block;width:100vw;height:100vh';
document.body.append(canvas);

// The backend supplies the three corners of a fullscreen triangle, so the vertex
// shader passes its position through. `position` is the attribute name it fills.
const VERTEX = `#version 300 es
in vec3 position;
void main() { gl_Position = vec4(position, 1.0); }`;

const FRAGMENT = `#version 300 es
precision highp float;
uniform float uTime;
uniform vec3 iResolution;
out vec4 fragColour;
void main() {
  vec2 uv = gl_FragCoord.xy / iResolution.xy;
  fragColour = vec4(0.5 + 0.5 * cos(uTime + uv.xyx * 6.283185 + vec3(0, 2, 4)), 1.0);
}`;

// One frame: one pass, one pipeline, drawn over the canvas.
const frame = glslFrame('first-frame', VERTEX, FRAGMENT);

// Both factories return promises, because the backend behind them is a dynamic import.
const surface = await createSurface(canvas, frame, {
  uniforms: (seconds) => ({ uTime: seconds, iResolution: [canvas.width, canvas.height, 1] }),
  onError: (message) => console.error(message),
});

if (!surface) throw new Error('no backend would give this canvas a context');

const fit = () => surface.resize(canvas.clientWidth, canvas.clientHeight);
addEventListener('resize', fit);
fit();
surface.start();

The window fills with a gradient that moves, and it follows a resize. wgslFrame is the same call for WGSL, though a fullscreen WGSL frame draws on WebGPU only, since there is no vertex document for WebGL 2 to link. docs/EXAMPLES.md breaks this page into the four files it really is, and lists six longer ones that run in this repository.

How it works

The frame is data. A frame graph is a plain object: resources, pipelines, passes. It serialises, so a worker can build one and post it. Two of them can be compared. cost(graph, size) gives you passes, draws, dispatches and transient bytes with no device present.

Capabilities are data too. A graph lists what it needs, a device reports what it has, and refusal compares the two and gives you the name of what is missing, or null. Neither backend owns a method the other has to throw from, so a graph WebGL 2 cannot draw is refused before a driver sees it.

You do not name a backend. selectBackend reads two facts and answers with one: the language the frame is authored in, and what the device offers. Where both backends can draw a frame they draw the same picture. On an RTX 5080 they agree to within a single channel on every scene preset in this repository's corpus.

A browser downloads one backend. Each sits behind await import(), so a bundler gives each its own file and a browser without WebGPU never downloads the WebGPU one. npm test bundles the entry point and fails if either backend turns up in the first download.

What a page downloads

From the chunk gate, gates/chunk.mjs, minified with splitting on, measured on the commit you are reading:

| chunk | raw | gzipped | downloaded | | --- | --- | --- | --- | | the entry point and your own calls | 3.7 kB | 1.8 kB | always | | the WebGL 2 backend | 21.6 kB | 7.1 kB | where it draws | | the WebGPU backend | 25.4 kB | 8.9 kB | where it draws | | four shared chunks | 15.0 kB | 5.9 kB | as a graph needs them |

A browser with no WebGPU downloads the first row, the second, and whichever shared chunks its graph reaches. That is about 15 kB gzipped at most, and never the WebGPU backend. No page downloads a WGSL translator either, because translation happens in the build.

Five things to know before you start

  1. Both factories are asynchronous. The backend behind them is a dynamic import, so createSurface and createFrameRenderer return promises.
  2. openRenderer(canvas, frame) picks the backend for you, asks for a card only where the answer wants one, translates the frame where the chosen backend speaks another language, and hands back { renderer, frame } or { refusal }. Submit the frame it hands back, since a WGSL frame drawn on WebGL 2 is drawn as its GLSL translation. createFrameRenderer is the primitive underneath and uses WebGL 2 unless you give it a WebGPU device — you call requestWebGPUDevice() and pass { backend: 'webgpu', device } — so a page that never wants WebGPU never downloads that backend. Either way the card is yours to own: a page with more than one canvas asks once and hands the same device to each.
  3. Do not call getContext('webgl2') on the canvas you are going to draw into. A canvas keeps the first context type it is given and refuses every other one for as long as it lives, so that call as a capability check breaks WebGPU on the machines that have it. Ask a throwaway canvas instead.
  4. A GLSL frame goes to WebGL 2 even where WebGPU exists. GLSL ES 3.0 has no syntax for the capabilities WebGPU adds, so the language you wrote in settles which backend draws.
  5. wgslFrame expects two conventions of its source: the fragment entry point is called fragMain, and the uniforms are one struct at group 0, binding 0. uniformBlockOf(code) reads the offsets off the source, so you never write the layout down twice.

Past a fullscreen shader

Real geometry, several passes, a depth buffer or a compute stage mean writing the graph yourself: resources, pipelines and passes, with every resource addressed by a kind-branded handle like uniform(0) or texture(2), never by a string. Hand a texture handle to something expecting a buffer and it is a compile error, not an undefined at draw time. docs/GUIDE-frame-graph.md builds one line by line.

There is a scene layer above the renderer: entities carry a transform and may name a parent, and sceneView turns a world and its cameras into a frame graph, packing each object's record into a storage buffer the shader indexes by instance. A rotation is a Mat4 and never three angles, because Euler orders disagree between codebases and nothing tells you when they do. The maths is vec3, mat3 and mat4, column major, depth from zero to one. A vector's length is vec3.magnitude, since a function's own length in JavaScript cannot be replaced. Every signature is in docs/API.md.

Those three names have a second import path, @altpsyche/engine/maths, and they stay on the main one as well — so this is a shortcut and never a move. It is worth using only if you have no bundler: reaching mat4 through the package name loads the renderer with it, twenty-seven files against one, and a bundler shakes that difference down to two gzipped bytes. With a bundler, keep the one import.

Ask before you draw

import {
  cost, requestWebGPUDevice, resolve, webgl2Capabilities, webgpuCapabilities,
  type DeviceProfile,
} from '@altpsyche/engine';

// Ask for the device first: whether asking returns one is the fact selection reads.
const device = await requestWebGPUDevice();
const gl = document.createElement('canvas').getContext('webgl2'); // a throwaway canvas

const profile: DeviceProfile = {
  webgpu: device ? webgpuCapabilities(device.features) : null,
  webgl2: gl ? webgl2Capabilities(gl.getSupportedExtensions() ?? []) : null,
};

// One reading: the backend that will draw this frame, or a refusal saying what is missing.
const selection = resolve(frame, profile);
if ('refusal' in selection) console.error(selection.refusal);
else console.log('drawing on', selection.backend);

// What it costs, before a pixel exists.
const { passes, draws, transientBytes } = cost(frame, { width: 800, height: 600 });

resolve does selection and the capability check in one call. selectBackend and refusal are the two halves on their own. All three are pure functions over data, so they answer in a test or in a worker, on a machine with no graphics card in it.

probe() is a different thing, easy to mistake for these. It draws a frame and returns a dated DeviceReading: what the browser reported, what came back when asked, whether the device survived being composited. It is a diagnostic, and a row for docs/DEVICES.md. Selection reads a DeviceProfile, not a reading.

Drawing one frame, on your own schedule

import { createFrameRenderer, requestWebGPUDevice, submit } from '@altpsyche/engine';

const device = await requestWebGPUDevice();
const renderer = await createFrameRenderer(canvas, device ? { backend: 'webgpu', device } : {});
if (!renderer) throw new Error('no backend would give this canvas a context');

submit(renderer, frame, { u_time: 0 });          // uniforms are an argument, not an option

A fourth argument, { into }, lands the frame in a texture you own as well as on the canvas: a capture target, or an XR layer the compositor reads. Where a frame lands is yours to decide.

Checking what your shader asked the device

The recording double is part of the package. wrapDevice wraps a real device and records every call made on it, projectTrace cuts a recording down to the calls worth comparing, and compareTraces says where two recordings differ. So you can show that editing a shader did not quietly change what the device was asked to do. The package uses the same three calls on its own two backends, and docs/API.md shows them in use.

What it needs

Any browser with WebGL 2, which is every current one, and WebGPU where you want that backend. TypeScript declarations ship with the package, so there is no @types install.

The two backends are not equal, and the difference is in the capability data. WebGL 2 draws the whole toy tier and the scene tier's per-instance records. It has no compute stage, no shader-written storage buffer, no storage texture, no indirect draw, no timestamp query and no occlusion query, because GLSL ES 3.0 has none of them. Ask probe or webgl2Capabilities instead of trusting this paragraph; docs/GUIDE-backends.md has the rest.

Status

0.x, and 0.x is unstable. Names and shapes change between releases without a major bump, and the CHANGELOG says what moved in each one. A caret range on a 0.x version tracks the last number alone, so ^0.3.0 will not pick up a later 0.4.0. You move to a feature release by asking for it.

Where to read next

| document | what it answers | | --- | --- | | docs/EXAMPLES.md | a complete page as four files, and the six that run in this repository | | docs/API.md | every exported name with the signature the compiler gives it | | docs/GUIDE-frame-graph.md | writing a frame graph by hand | | docs/GUIDE-backends.md | capabilities, selection, refusal, and what each backend reaches | | docs/ARCHITECTURE.md | how the library is put together, and why | | CHANGELOG.md | what moved in each release |

If you are changing the package instead of using it, CONTRIBUTING.md has the gates and the rules that are not negotiable, and docs/DEVICES.md is the hardware log: dated readings from real machines, kept so a claim about a graphics card has a source.

Licence

MIT.