BLIT386BLIT386
DocumentationPerformance

Performance Best Practices

When and how to optimize demos: object allocation, batching, and hot-path guidance.

This guide explains when and how to optimize your BLIT386 demos for performance, with a focus on object allocation patterns and rendering efficiency.

Introduction

When to optimize

Profile first, optimize second

Premature optimization often leads to complex, hard-to-maintain code without meaningful performance gains.

Follow this approach:

Write clear, simple code first - Use inline object creation (new Vector2i(x, y))

Measure performance - Use browser dev tools to identify bottlenecks

Optimize hot paths only - Focus on code that runs 50+ times per frame

Keep it simple elsewhere - Clarity matters more than micro-optimizations in UI code

Frame budget

Each render frame (one requestAnimationFrame callback) must finish before the next vsync. On a 60 Hz display that is about 16.66 ms per frame (1000 / 60). That budget covers:

  • Zero or more fixed update() steps at BT.targetFPS (~2-4 ms total when one step runs)
  • One render() pass (~8-12 ms)
  • Browser overhead (~2-4 ms)

If work exceeds the display's render cadence, you drop frames and see stuttering. That is independent of BT.targetFPS: a 60 Hz simulation on a 120 Hz monitor still has a ~8.33 ms render budget per rAF callback.

Object allocation patterns

JavaScript's garbage collector can cause frame hitches if you allocate many objects per frame. BLIT386 provides two patterns for managing allocations:

When to use: UI code, one-time operations, and anywhere readability matters more than performance.

// Clear and readable - palette indices, not Color32
const  = 1;
.(new (, ), );
.(new (10, 10, 50, 50), );
.(, new (10, 20), 'Hello');

Pros:

  • Easy to read and understand
  • No boilerplate
  • Self-documenting code

Cons:

  • Creates new objects each call
  • Can cause GC pressure in tight loops

Comparison

For a loop drawing 200 pixels per frame at 60 FPS:

PatternAllocations/secGC Impact
Inline12,000/secModerate GC pauses every ~1 second
Pre-allocation0/secMinimal GC activity

Real-world impact: On modern browsers, inline allocation is fine for <50 operations/frame. Beyond that, pre-allocation provides measurable benefits.

Drawing performance

Batching

BLIT386 automatically batches draw calls for optimal GPU performance:

  • Primitive batching: All primitives (pixels, lines, rects) are batched together
  • Texture batching: Sprites from the same SpriteSheet are batched
  • Batch breaks: Changing sprite sheets breaks batching

Optimization tips:

// Good: All sprites from same sheet
for (const  of ) {
  .(, ., ., .);
}

// Less optimal: Switching between sheets
.(, , );
.(, , ); // Batch break!
.(, , ); // Batch break!

Sprite sheet organization

Pack related sprites into single sheets:

Good:
- enemies.png (all enemy sprites)
- ui.png (all UI elements)
- environment.png (tiles, props)

Less optimal:
- enemy1.png, enemy2.png, enemy3.png (separate files)

Palette offset performance

Changing paletteOffset per draw is cheap - it shifts stored texel indices before palette lookup, not RGBA tint multiplication:

// Same performance - offset is a uniform add, not per-pixel color math
.(, , );
.(, , , 16); // team-color range

Use palette offsets liberally for team colors and damage flashes - batching cost dominates, not the offset itself.

Allocation-free draw calls

The sprite draw calls added in 1.8.0 allocate nothing per call, so they are safe in the hottest loop:

  • BT.drawTile(sheet, index, pos) and BT.drawTile(sheet, col, row, pos) reuse one internal source rectangle; there is no Rect2i to build or cache per tile.
  • BT.drawSprite(sheet, src, dest, params) with a SpriteDrawParams object resolves flags, scale, and pivot into engine-owned scratch. The engine reads the object and never retains it, so one params object rewritten per draw is enough - do not build a fresh literal per sprite.
  • BT.drawNineSlice(panel, rect) is one draw call however many parts it emits. Build the NineSlice once in init(). Tiled edges and centers draw one quad per tile, so tiling a 1-pixel strip across a large box costs many quads; stretch flat fills instead and keep tiling for art that needs it.

Line rendering costs

The Renderer has been optimized to reduce vertex overhead: axis-aligned lines now render as single quads (6 vertices), while diagonal lines use Bresenham's algorithm. This section explains the cost tradeoffs and when optimization matters.

Line rendering performance varies significantly based on line orientation:

Line TypeVertices UsedExample (100px line)
Horizontal / Vertical6 (single quad)6 vertices
Diagonal6 per pixel (Bresenham)~600 vertices

Why diagonal lines cost more:

Diagonal lines use Bresenham's algorithm to achieve authentic pixel-art rendering where each pixel is a discrete unit. This produces the classic "staircase" look expected in retro demos but requires rendering each pixel as a separate quad.

Optimization tips for diagonal lines:

// GOOD: Grid lines (axis-aligned) are very cheap
for (let  = 0;  < 800;  += 40) {
  .(new (, 0), new (, 600), ); // 6 vertices each
}

// EXPENSIVE: Many diagonal lines (e.g., Lissajous curves, circles)
// Each segment uses ~6 vertices per pixel
for (let  = 0;  < 200; ++) {
  .(, , ); // Could be 600+ vertices per line
}

// ALTERNATIVE: Use filled rectangles for thick diagonal lines
// Or pre-render complex patterns to a texture

When diagonal line cost matters:

  • Drawing 10-20 short diagonal lines per frame: Fine
  • Drawing 100+ diagonal line segments (curves, circles): Consider alternatives
  • Static complex patterns: Pre-render to a sprite sheet

Loop best practices

Fixed timestep

BLIT386 uses a fixed simulation timestep by default (targetFPS: 60 → update() about 60 times per second). render() still runs at the browser's refresh rate. This provides:

  • Deterministic behavior: Same inputs always produce same results
  • Predictable physics: Use BT.ticks and BT.deltaSeconds instead of render-frame timing
  • Simplified logic: Tick-based counters and Timer intervals

Using ticks for timing

// Frame-based timer (recommended)
if (. -  >= 60) {
  ();
   = .;
}

// Alternative: Delta time (more complex)
 += ;
if ( >= 1.0) {
  ();
   = 0;
}

Ticks are simpler and more deterministic for most demo logic.

Frame budget management

If your update() or render() takes too long:

  1. Confirm the symptom - Set isDetectingDroppedFrames: true in configure() to log a console warning whenever the browser misses a vsync deadline. The detector auto-calibrates to the actual rAF cadence so it works on any refresh rate (60 / 120 / 144 Hz, etc.) and on Firefox where rAF often fires at the display rate rather than at targetFPS.
  2. Profile with browser dev tools - Find the actual bottleneck
  3. Reduce draw calls - Cull off-screen objects
  4. Optimize hot loops - Use pre-allocation in tight loops
  5. Simplify logic - Can you defer expensive operations?

Common pitfalls

Performance rules of thumb

Object allocation guidelines

Operations/FrameRecommendationExample
< 10Inline allocation fineUI text, menu rendering
10-50Borderline, prefer claritySimple animations, small particle systems
50-100Consider pre-allocationComplex scenes, medium particle systems
100+Definitely pre-allocatePattern demos, large particle systems

When to use each pattern

Use inline allocation for:

  • UI rendering (< 10 operations)
  • Menu systems
  • Initialization code
  • Debug rendering
  • Anything that runs once or rarely

Use pre-allocation for:

  • Particle systems (100+ particles)
  • Complex pattern rendering
  • Large scrolling worlds (camera example)
  • Per-entity operations with 50+ entities
  • Physics simulations

Example references

Demos live in packages/demos (one number-free kebab-case src/<slug>.js file per demo, e.g. basics.js). The examples below demonstrate both approaches:

Clarity-first examples (inline allocation)

  • basics.js - Entry-level example teaching core concepts
  • bitmap-font.js - Bitmap font loading and palette indexize workflow
  • primitives.js - Drawing primitive shapes
  • sprites.js - Sprite rendering and palette offsets
  • fonts.js - System font and text rendering

These prioritize readability and learning over micro-optimizations.

Performance-optimized examples (pre-allocation)

  • patterns.js - Complex animations with 200+ operations/frame
  • camera.js - Scrolling world with many buildings
  • bunnymark.js - Bouncing sprites by the hundred from one sheet, with the WebGPU sprite cap made visible
  • nine-slice-panels.js - One NineSlice per panel style, drawn at every size from a single asset

These demonstrate when and how to optimize for performance-critical scenarios.

Effect and animation examples

  • animation.js - Tick-based timing (inline allocation, focus on clarity)
  • sprite-effects.js - Palette offset effects for team colors and flashes

Conclusion

Start simple, optimize when needed:

  1. Write clear code with inline allocation
  2. Profile to find actual bottlenecks
  3. Optimize hot paths with pre-allocation
  4. Keep UI and one-time code simple

Remember

Clear code that runs at 60 FPS is better than complex code that runs at 65 FPS.

See also

Last updated on October 18, 2026

On this page