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

brushengine

v0.1.2

Published

Hand-drawn 2-point-perspective brush renderer with a generative cityscape.

Readme

Brush Engine

A small WebGL engine (React + TypeScript + three.js, scaffolded with Vite) that renders collections of 3D paths as flat brush strokes under a custom 2-point perspective projection with directly placeable vanishing points.

npm install
npm run dev      # http://localhost:5173  (playground / editor)
npm run build    # type-check + playground production build (-> dist-app/)
npm run build:lib  # build the distributable package (-> dist/)

Use as a package

The renderer ships as a framework-agnostic library (src/lib/index.ts), built with Vite library mode (npm run build:lib). It has no React dependency and a single peer dependency on three. The two brush SVGs are base64-inlined (src/engine/brushData.ts, regenerate with npm run gen:brushes), so the package is self-contained — nothing to copy or serve.

The package has two entry points: brushengine is the renderer API (BrushEngine, brush loading, projection math, types); brushengine/scene is the renderer-free scene toolkit (the cityScene generator, stroke/vector helpers, palette, data types) — no three, no DOM, SSR-safe.

import { BrushEngine, loadBrushTextures } from "brushengine";
import { cityScene, DEFAULT_CITY } from "brushengine/scene"; // no three, SSR-safe

const engine = new BrushEngine(canvas);          // a <canvas> element
engine.setBrushes(await loadBrushTextures());    // inlined data URIs by default
engine.setStrokes(cityScene(DEFAULT_CITY));
engine.resize(canvas.clientWidth, canvas.clientHeight);
engine.start();                                  // dirty-flagged loop; idle = free

The engine touches document at import time, so on SSR frameworks (Next, etc.) import and construct it client-side only (dynamic import in an effect).

brushengine/scene is also where the stroke-authoring toolkit lives, so you can build your own scenes (not just the bundled city) without the renderer — the line wobble helper, the Vec3 math (add, sub, scale, lerp, norm, cross), the PALETTE, and the StrokeData types:

import { line, PALETTE, type StrokeData } from "brushengine/scene";

const strokes: StrokeData[] = [
  { points: line([0, 0, 0], [0, 1, 0]), style: { color: PALETTE.teal, widthPx: 12, brush: 0, opacity: 1 } },
];

Parallax on scroll

The vanishing points are runtime projection params, so a scroll-driven parallax is just lerping them and calling setProjection — no geometry rebuild:

const base = engine.getProjection();
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;

function onScroll(t: number) {        // t = 0..1 scroll progress through the section
  engine.setProjection({
    ...base,
    vpX:    { x: lerp(base.vpX.x,  1.3, t), y: lerp(base.vpX.y, 0.1, t) },
    vpZ:    { x: lerp(base.vpZ.x, -1.3, t), y: lerp(base.vpZ.y, 0.1, t) },
    origin: { x: base.origin.x,             y: lerp(base.origin.y, -0.4, t) },
  });
}

Keep VP moves modest and perspective low so every corner's homogeneous depth stays > 0 (see the projection note below). Throttle onScroll to rAF and only run it while the canvas is on screen.

The package is currently "private": true and named brushengine. To publish, pick a (scoped) name and remove private from package.json.

What it does

  • Strokes are 3D polylines, drawn as screen-space ribbons so the brush texture always faces the camera (billboarding) without any explicit billboard math — the expansion happens perpendicular to the projected tangent.

  • Stroke thickness vs. distance is a single knob: constant screen width regardless of depth, full perspective foreshortening, or anything in between (and beyond, for exaggeration).

  • The camera is a custom projective 2-point perspective: you place the two horizontal vanishing points anywhere on screen (drag the pink handles), and vertical world lines always stay parallel. Focal length, vertical scale, and a 2D zoom are separate knobs.

  • Brush textures are bundled SVGs (public/brushes/, base64-inlined into the package via src/engine/brushData.ts); the ink lives in the alpha channel and is recoloured per stroke. Strokes pick a brush per stroke, with a global override in the UI.

  • CMYK ink mix (toggle, on by default): overlapping strokes multiply like real ink, so crossings darken — even two strokes of the same colour — while the canvas stays transparent so the page shows through. It's a two-pass pipeline (BrushEngine.renderOnce + the composite shaders):

    1. Strokes are drawn into an offscreen target cleared to white. RGB blends with a multiply (dst * src) using a coverage-weighted mix(white, colour, a) factor — so the target accumulates exactly the opaque-white result T (full ink darkens fully, faint/edge pixels barely darken). Alpha blends "over" to record coverage.
    2. A fullscreen pass un-premultiplies T against white (rgb = (T-1+a)/a, alpha = a) onto the transparent canvas. Over the (white) page this reproduces T at full strength — no fading — yet stays see-through where there's no ink, and composites correctly over any background.

    The target is half-float + MSAA (the un-premultiply divides by small alphas, so 8-bit would band, and MSAA keeps edges smooth). Multiply is order-independent, so no per-stroke sorting is needed. Turn it off for ordinary alpha blending (single pass, straight to the canvas).

How the 2-point projection works

The projection is a 3×4 homogeneous matrix whose columns are the screen images of the four world basis points (see src/engine/types.ts and shaders.ts):

| world homogeneous point | maps to | | --- | --- | | X axis at infinity (1,0,0,0) | vpX — the +X vanishing point | | Z axis at infinity (0,0,1,0) | vpZ — the +Z vanishing point | | Y axis at infinity (0,1,0,0) | straight up → verticals stay parallel | | world origin (0,0,0,1) | origin on screen |

For a world point (x, y, z):

hx = x·k·vpX.x + z·k·vpZ.x + origin.x
hy = x·k·vpX.y + y·s + z·k·vpZ.y + origin.y       // s = verticalScale
hw = x·k       + z·k             + 1               // k = perspective strength
ndc = (hx/hw, hy/hw) · zoom

As x → ∞ the point converges exactly to vpX; as z → ∞, to vpZ; vertical lines never converge (no third vanishing point). hw is the homogeneous depth term (≈1 near the origin, larger with distance) and drives the thickness knob: width *= mix(1, 1/hw, thicknessFalloff).

Performance

The generative city can emit a few thousand strokes. To keep it smooth:

  • Batching — strokes are grouped by brush and merged into one geometry per group (colour / width / opacity baked into vertex attributes), so the scene draws in ~3 calls instead of one per stroke.
  • Dirty flag — the render loop only does work when projection / params / size actually change; between interactions it early-outs, so an idle scene costs nothing.
  • No per-frame sort — multiply (ink-mix) blending is order-independent, so strokes are never depth-sorted. With ink-mix off (plain alpha), overlap order follows batch order rather than strict back-to-front; for the sketch aesthetic that's not noticeable, and it keeps dragging at 60 fps.

Layout

src/
  engine/
    types.ts        projection params, stroke + style types
    projection.ts   CPU mirror of the GLSL projection (guides, sorting)
    shaders.ts      vertex (projection + ribbon expansion) + fragment shaders
    Stroke.ts       merge many strokes -> one batched ribbon geometry
    brushes.ts      load bundled SVG strokes as textures
    brushData.ts    base64-inlined brush SVGs (generated; npm run gen:brushes)
    BrushEngine.ts  renderer, per-brush batched meshes, dirty-flagged render loop
  lib/
    index.ts        "brushengine" — renderer API (engine + projection + types)
    scene.ts        "brushengine/scene" — renderer-free scene toolkit + demos
  scenes/
    helpers.ts      subdivide + wobble a segment into a hand-drawn polyline
    box.ts          gridded building
    house.ts        gable house
    city.ts         generative cityscape (seeded, parametric)
  components/
    Stage.tsx       canvas + SVG overlay (draggable VP handles, guide lines)
    ControlPanel.tsx
  App.tsx
public/brushes/     stroke1.svg (scratchy), stroke2.svg (bold)

Generative city scene

scenes/city.ts builds an architect's-sketch skyline as a pure function of (seed, params), so it's fully reproducible. Towers sit on an N×N footprint grid viewed from the near corner; a Gaussian height envelope (and occupancy bias) makes the centre towers tallest. Each tower is assembled from box edges (often left partial / "half-defined"), façade grids, intermittent window hatching, a central spire, and construction guidelines that overshoot toward the vanishing points.

Randomness comes from a seeded mulberry32 PRNG — no Math.random/Date in the generator — so the same inputs always yield the same city. The panel exposes a numeric seed plus a base64 scene code (encodeCity/decodeCity) that captures seed + all params; copy it to reproduce the exact city on another client. Exclusive sliders: buildings (grid size), height peak, height variability (downward-only — adds more low-rises while preserving the peak), window density, grid-face density, grid variability (per-face roll that spreads façade grids symmetrically from very dense to very sparse / nearly gone — 0 means every face matches), grid gaps (chunks the grid along the face height — 0 saturates top-to-bottom, mid values give a single partial chunk, high values add a striped multi-chunk mode), grid ratio (per-face chance to stretch grid cells to a non-square aspect, up to 16:1 / 1:16 at full value), guideline density, half-box ratio, size variation (footprint width/depth spread — also affects the half-defined boxes), L-shapes (fraction of buildings drawn as a tall tower + lower "foot" wing — built as a single L-solid with no interior seam, so the side reads as one continuous face; the seed rolls a vertical flip for top-/bottom-heavy massing and a 4-way yaw for the foot direction), path wobble (hand-drawn waviness on every stroke), and guideline length (overshoot length of the construction guidelines).

Switching to the city scene also applies a worm's-eye default camera (CITY_PROJECTION).

Adding your own strokes

A stroke is just { points: Vec3[], style: { color, widthPx, brush, opacity } }. Build a StrokeData[] (see src/scenes/) and pass it to <Stage strokes={...} />. Drop more SVG/PNG files in public/brushes/, list them in src/engine/brushes.ts, and reference them by index via style.brush.