Skip to content

perf: faster rendering with identical output, lifecycle and library fixes, bun toolchain - #1

Merged
ignmandagaran merged 26 commits into
mainfrom
ignmandagaran/play.core-performance-review
Oct 9, 2026
Merged

ignmandagaran merged 26 commits into
mainfrom
ignmandagaran/play.core-performance-review

Conversation

@ignmandagaran

Copy link
Copy Markdown
Owner

Summary

Makes the renderer fast without changing what it draws, fixes the render loop lifecycle and several library bugs, and modernises the toolchain. The torus demo used ~13 ms of a 16.7 ms frame and ran at ~17 FPS under 4× CPU throttling; it now runs at ~35–38 FPS there. Output stays identical: the canvas renderer is tested against a frozen copy of the original, and SDF results are bit-identical. Also switches the repo to bun (package manager and tests), adds a test suite and CI, and fixes the published typings and package license.

Behaviour changes, released as a minor via changesets:

  • boot now runs; it never did before.
  • Pausing (hidden tab, scrolled out of view) keeps buffer, frame count and userData. Changing program, settings or loop restarts and runs boot.
  • The fps cap is accurate: no dropped frames when fps matches the display, never above the cap.
  • loop may return an unsubscribe function.
  • The buffer resets when the grid size changes.
  • The text renderer escapes chars and styles (beginHTML/endHTML stay raw).
  • keyDown works: the element is focusable when the program defines it.
  • loadJson is typed Promise<unknown> instead of any.

Changes

  • Performance:
    • Lean canvas renderer: resizes only when the size changes, sets the font once per frame, dedupes fillStyle, caches glyph widths for centered text.
    • Allocation-free sdBox/sdSegment.
    • Torus demo precomputes edge data once per frame.
  • Render loop: separate session and run effects, a single rAF id, a fixed-schedule frame cap, settings compared two levels deep, per-instance renderers, and callbacks from a stale external loop ignored.
  • Text renderer: escapes cell characters and style values before writing rows as HTML.
  • Metrics and input: re-measure character metrics after a web font loads; deliver keyDown events.
  • Module bugs: drawBox colors and font weight under text, fractional/NaN buffer coordinates, distSqVec3 z, exportFrame without extension, JPEG MIME type.
  • Typings: fixed the published module declarations; typed canvasSize, canvasOffset and context.settings.element; removed the duplicate types file.
  • Tests: bun test with happy-dom, 72 tests (renderer equivalence via a recording 2D context, SDF bit-identity, frame cap across refresh rates, text renderer, component lifecycle, module helpers, declaration/export parity), plus a consumer type check against dist.
  • Tooling:
    • Switched to bun.
    • Upgraded Vite 8, ESLint 10, TypeScript 6 and in-range dependencies.
    • Fixed the ESLint config so lint actually runs.
    • Added a GitHub Actions CI workflow (lint, typecheck, test, build).
    • bun run dev no longer races tsup's watch build.
  • Package: license is Apache-2.0 (matches LICENSE and play.core) and LICENSE ships in the package; changesets targets master and skips the private examples app.
  • Docs: AGENTS.md with commands, the invariants the tests enforce, and release rules.

Checklist

  • bun lint passes
  • Tested locally in dev mode
  • No breaking changes (or documented in summary)

Import the typescript-eslint plugin's default export (its configs live there)
and turn off core no-undef for TS files, as typescript-eslint recommends.
Resize the canvas only when its size changes, set the font once per frame,
skip redundant fillStyle writes and fillText for whitespace, and cache glyph
widths for centered text (invalidated on font change and on web font load).
Renderers are now created per component instance.
Compute with plain numbers instead of vec2 helpers that allocate per call.
Same operation order, so results are bit-identical.
Project vertices into reused objects, precompute per-edge terms in pre and take
one square root per cell. Distances are bit-identical to the previous version.
- Run boot when the program starts and size the buffer to the grid
- Keep buffer, frame count and userData across pause/resume
- Ignore frame callbacks after a run stops; let loop return an unsubscribe
- Schedule capped frames on a fixed timeline so fps is accurate
- Compare settings structurally so inline objects don't restart the program
- Give each text renderer its own back buffer
- Use a single rAF id and build the context once per frame
Includes React 19.3, Vite 6.4, turbo 2.11, typescript-eslint 8.71, tsup 8.5,
and TypeScript 5.9.
Upgrade @eslint/js 10, globals 17, eslint-plugin-react-hooks 7 and
eslint-plugin-react-refresh 0.5. Drop eslint-plugin-react, which was never
configured and does not support ESLint 10. Avoid a no-useless-assignment hit
in the render loop.
Drop the unused baseUrl (deprecated in TS 6). tsup's declaration build injects
baseUrl itself, so the library tsconfig sets ignoreDeprecations until the
build moves off tsup.
Replace pnpm (lockfile and workspace file) with bun workspaces; the root
package.json already declared them. Release scripts call the binaries directly.
- Canvas renderer: draws the same as the pre-rewrite renderer (kept as a test
  oracle) across settings, device pixel ratios and font loads, using a
  recording 2D context
- sdf: bit-identical to the previous implementation, including edge cases
- Frame cap: accurate rate at 60/120/144/240 Hz with jitter, no burst after stalls
- Text renderer: row output, partial updates, per-instance state
- Component lifecycle: boot, pause/resume, settings and userData updates,
  external loops, unmount

Adds bun test (happy-dom), typecheck scripts and turbo tasks.
Rows are written with innerHTML, so a cell char like "<img ...>" or a color
containing a quote was parsed as markup. Escape both; beginHTML/endHTML stay raw.
Rewrite the hand-written module declarations against their implementations,
type canvasSize/canvasOffset and context.settings.element, and drop the
duplicate types/index.d.ts. Add a test that declared exports match runtime
exports, and a consumer fixture compiled against dist with skipLibCheck off
(check:types).
- Declare Apache-2.0 (matching LICENSE and the upstream play.core) and ship
  the LICENSE in the npm package
- Point changesets at master and stop versioning the private examples app
- Release the render loop changes as a minor: they change behaviour
keydown was listened for on a pre/canvas that could never take focus, so
program.keyDown never ran; make the element focusable when the program
handles keys. Re-measure character metrics on document.fonts loadingdone.
…wnload

- drawBox merged the raw style (and a misspelled fontWeight) into text cells,
  clearing the box colors under the text
- buffer helpers indexed with fractional/NaN coordinates, adding stray keys
- distSqVec3 ignored z
- exportFrame threw on filenames without an extension
- .jpg was saved as text/jpeg
turbo dev started the demo app while tsup --watch was wiping dist, so Vite
cached a failed resolution of react-ascii-play and served 500s. Build the
library before dev tasks start and don't clean dist in watch mode.
Without the per-frame resize, an invalid backgroundColor left fillStyle at the
previous frame's last cell color and painted the whole canvas with it. Reset
it to the default first, as the resize did.
Instances without a userData prop shared one module-level object, so state a
program stored in it leaked between instances and across remounts.
Restore inputs the implementation supports: sortAscii takes a string or an
array, setBuffer/setRectBuffer take a cell or a character. Use typescript-eslint's
eslint-recommended overrides so overload declarations don't trip no-redeclare.
@ignmandagaran
ignmandagaran merged commit 9081e9e into main Oct 9, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant